Compare commits

..
5398 changed files with 137821 additions and 336687 deletions
+131 -116
View File
@@ -1,95 +1,84 @@
version: 2.1 # Ruby CircleCI 2.0 configuration file
orbs: #
node: circleci/node@6.1.0 # Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
machine: docker:
image: ubuntu-2204:2024.05.1 # specify the version you desire here
resource_class: large - image: cimg/ruby:3.2.2-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/
- image: cimg/postgres:15.3
- image: cimg/redis:6.2.6
environment: environment:
RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
COVERAGE: true - COVERAGE: true
LOG_LEVEL: warn - LOG_LEVEL: warn
parallelism: 4 parallelism: 4
resource_class: large
jobs: jobs:
build: build:
<<: *defaults <<: *defaults
steps: steps:
- checkout - checkout
- node/install:
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run: - run:
name: Install System Dependencies name: Configure Bundler
command: | command: |
sudo apt-get update echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ source $BASH_ENV
libpq-dev \ gem install bundler
redis-server \
postgresql-common \
postgresql-16 \
postgresql-16-pgvector \
build-essential \
git \
curl \
libssl-dev \
zlib1g-dev \
libreadline-dev \
libyaml-dev \
openjdk-11-jdk \
jq \
software-properties-common \
ca-certificates \
imagemagick \
libxml2-dev \
libxslt1-dev \
file \
g++ \
gcc \
autoconf \
gnupg2 \
patch \
ruby-dev \
liblzma-dev \
libgmp-dev \
libncurses5-dev \
libffi-dev \
libgdbm6 \
libgdbm-dev \
libvips
- run: - run:
name: Install RVM and Ruby 3.3.3 name: Which bundler?
command: | command: bundle -v
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler -v 2.5.16
- run: - run:
name: Install Application Dependencies name: Swap node versions
command: | command: |
source ~/.rvm/scripts/rvm set +e
bundle install wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
# pnpm install export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
nvm install v20
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
# Run bundler
# Load installed gems from cache if possible, bundle install then save cache
# Multiple caches are used to increase the chance of a cache hit
- restore_cache:
keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- run: bundle install --frozen --path ~/.bundle
- save_cache:
paths:
- ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way
- restore_cache:
keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
- chatwoot-yarn-
- run:
name: yarn
command: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- run: - run:
name: Download cc-test-reporter name: Download cc-test-reporter
@@ -97,8 +86,12 @@ jobs:
mkdir -p ~/tmp mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
# Swagger verification # verify swagger specification
- run: - run:
name: Verify swagger API specification name: Verify swagger API specification
command: | command: |
@@ -111,62 +104,48 @@ jobs:
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests
- run:
name: Database Setup and Configure Environment Variables
command: |
pg_pass=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 15 ; echo '')
sed -i "s/REPLACE_WITH_PASSWORD/${pg_pass}/g" ${PWD}/.circleci/setup_chatwoot.sql
chmod 644 ${PWD}/.circleci/setup_chatwoot.sql
mv ${PWD}/.circleci/setup_chatwoot.sql /tmp/
sudo -i -u postgres psql -f /tmp/setup_chatwoot.sql
cp .env.example .env
sed -i '/^FRONTEND_URL/d' .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
# Database setup # Database setup
- run: - run: bundle exec rake db:create
name: Run DB migrations - run: bundle exec rake db:schema:load
command: bundle exec rails db:chatwoot_prepare
# Bundle audit
- run: - run:
name: Bundle audit name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run: - run:
name: Rubocop name: Rubocop
command: bundle exec rubocop command: bundle exec rubocop
# ESLint linting # - run:
# name: Brakeman
# command: bundle exec brakeman
- run: - run:
name: eslint name: eslint
command: pnpm run eslint command: yarn run eslint
# Run frontend tests
- run: - run:
name: Run frontend tests name: Run frontend tests
command: | command: |
mkdir -p ~/build/coverage/frontend mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
pnpm run test:coverage TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out ~/tmp/test-results/yarn.xml \
-- ${TESTFILES}
- run: - run:
name: Code Climate Test Coverage (Frontend) name: Code Climate Test Coverage
command: | command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests # Run rails tests
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend mkdir -p coverage
~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \ bundle exec rspec --format progress \
@@ -174,18 +153,54 @@ jobs:
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
- run: - run:
name: Code Climate Test Coverage (Backend) name: Code Climate Test Coverage
command: | command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage
- persist_to_workspace: - persist_to_workspace:
root: ~/build root: coverage
paths: paths:
- coverage - codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run:
name: Upload coverage results to Code Climate
command: |
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build
-11
View File
@@ -1,11 +0,0 @@
CREATE USER chatwoot CREATEDB;
ALTER USER chatwoot PASSWORD 'REPLACE_WITH_PASSWORD';
ALTER ROLE chatwoot SUPERUSER;
UPDATE pg_database SET datistemplate = FALSE WHERE datname = 'template1';
DROP DATABASE template1;
CREATE DATABASE template1 WITH TEMPLATE = template0 ENCODING = 'UNICODE';
UPDATE pg_database SET datistemplate = TRUE WHERE datname = 'template1';
\c template1;
VACUUM FREEZE;
+1 -5
View File
@@ -27,8 +27,7 @@ checks:
threshold: 50 threshold: 50
exclude_patterns: exclude_patterns:
- 'spec/' - 'spec/'
- '**/specs/**/**' - '**/specs/'
- '**/spec/**/**'
- 'db/*' - 'db/*'
- 'bin/**/*' - 'bin/**/*'
- 'db/**/*' - 'db/**/*'
@@ -51,12 +50,9 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js' - 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js' - 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js' - 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js' - 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js' - 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'
+9 -12
View File
@@ -1,14 +1,10 @@
ARG VARIANT ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID vscode \ groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \ && usermod --uid $USER_UID --gid $USER_GID vscode \
@@ -31,10 +27,10 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
tmux \ tmux \
zsh \ zsh \
git-flow \ git-flow \
npm \ npm
libyaml-dev
# Install rbenv and ruby # Install rbenv and ruby
ARG RUBY_VERSION="3.2.2"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \ RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc && echo 'eval "$(rbenv init -)"' >> ~/.bashrc
@@ -69,7 +65,8 @@ COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install RUN gem install bundler && bundle install
# set up node js # set up node js
RUN npm install n -g && \ RUN npm install npm@latest -g && \
n $NODE_VERSION npm install n -g && \
n latest
RUN npm install --global yarn RUN npm install --global yarn
RUN yarn RUN yarn
+9 -19
View File
@@ -5,30 +5,19 @@
version: '3' version: '3'
services: services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# 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: app:
build: build:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: 'ubuntu-22.04' # Update 'VARIANT' to pick a Ruby version: https://github.com/microsoft/vscode-dev-containers/tree/main/containers/ruby
NODE_VERSION: '23.7.0' VARIANT: 3
RUBY_VERSION: '3.3.3' # [Choice] Install Node.js
INSTALL_NODE: "true"
NODE_VERSION: "lts/*"
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # 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_UID: 1000
USER_GID: '1000' USER_GID: 1000
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached
@@ -40,7 +29,7 @@ services:
network_mode: service:db network_mode: service:db
db: db:
image: pgvector/pgvector:pg16 image: postgres:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
@@ -64,3 +53,4 @@ services:
volumes: volumes:
postgres-data: postgres-data:
redis-data: redis-data:
+9 -27
View File
@@ -1,9 +1,4 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set # Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app # Replace with the URL you are planning to use for your app
@@ -28,9 +23,6 @@ FORCE_SSL=false
ENABLE_ACCOUNT_SIGNUP=false ENABLE_ACCOUNT_SIGNUP=false
# Redis config # Redis config
# specify the configs via single URL or individual variables
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
# You can also use the following format for the URL: redis://:password@host:port/db_number
REDIS_URL=redis://redis:6379 REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string, # If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose # which will be the password for the redis service running inside the docker-compose
@@ -85,13 +77,11 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server # Comment out the following environment variables if required by your SMTP server
# SMTP_TLS= # SMTP_TLS=
# SMTP_SSL= # SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN= MAILER_INBOUND_EMAIL_DOMAIN=
# Set this to the appropriate ingress channel with regards to incoming emails # Set this to appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# relay for Exim, Postfix, Qmail # relay for Exim, Postfix, Qmail
# mailgun for Mailgun # mailgun for Mailgun
@@ -101,20 +91,10 @@ MAILER_INBOUND_EMAIL_DOMAIN=
RAILS_INBOUND_EMAIL_SERVICE= RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service # Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
# Set this to a password of your choice and use it in the Inbound webhook
RAILS_INBOUND_EMAIL_PASSWORD= RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY= MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY= MANDRILL_INGRESS_API_KEY=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
# Note: Replace the values inside the brackets; do not include the brackets themselves.
# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails
# For Postmark
# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section.
# Storage # Storage
ACTIVE_STORAGE_SERVICE=local ACTIVE_STORAGE_SERVICE=local
@@ -190,6 +170,11 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Sentry ## Sentry
# SENTRY_DSN= # SENTRY_DSN=
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout ## Scout
## https://scoutapm.com/docs/ruby/configuration ## https://scoutapm.com/docs/ruby/configuration
@@ -214,7 +199,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Rack Attack configuration ## Rack Attack configuration
## To prevent and throttle abusive requests ## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true # ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300 # RACK_ATTACK_IP_LIMIT=3000
# ENABLE_RACK_ATTACK_WIDGET_API=true # ENABLE_RACK_ATTACK_WIDGET_API=true
## Running chatwoot as an API only server ## Running chatwoot as an API only server
@@ -253,8 +238,5 @@ AZURE_APP_SECRET=
## OpenAI key ## OpenAI key
# OPENAI_API_KEY= # OPENAI_API_KEY=
# Housekeeping/Performance related configurations # Sentiment analysis model file path
# Set to true if you want to remove stale contact inboxes SENTIMENT_FILE_PATH=
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
+10 -192
View File
@@ -2,38 +2,15 @@ module.exports = {
extends: [ extends: [
'airbnb-base/legacy', 'airbnb-base/legacy',
'prettier', 'prettier',
'plugin:vue/vue3-recommended', 'plugin:vue/recommended',
'plugin:vitest-globals/recommended', 'plugin:storybook/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
], ],
overrides: [
{
files: ['**/*.spec.{j,t}s?(x)'],
env: {
'vitest-globals/env': true,
},
},
{
files: ['**/*.story.vue'],
rules: {
'vue/no-undef-components': [
'error',
{
ignorePatterns: ['Variant', 'Story'],
},
],
// Story files can have static strings, it doesn't need to handle i18n always.
'vue/no-bare-strings-in-template': 'off',
'no-console': 'off',
},
},
],
plugins: ['html', 'prettier'],
parserOptions: { parserOptions: {
ecmaVersion: 'latest', parser: '@babel/eslint-parser',
ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
plugins: ['html', 'prettier', 'babel'],
rules: { rules: {
'prettier/prettier': ['error'], 'prettier/prettier': ['error'],
camelcase: 'off', camelcase: 'off',
@@ -49,160 +26,6 @@ module.exports = {
'import/no-unresolved': 'off', 'import/no-unresolved': 'off',
'vue/html-indent': 'off', 'vue/html-indent': 'off',
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/next-tick-style': ['error', 'callback'],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/component-options-name-casing': ['error', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error'],
'vue/define-macros-order': [
'error',
{
order: ['defineProps', 'defineEmits'],
defineExposeLast: false,
},
],
'vue/define-props-declaration': ['error', 'runtime'],
'vue/match-component-import-name': ['error'],
'vue/no-bare-strings-in-template': [
'error',
{
allowlist: [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'⌘',
'📄',
'🎉',
'💬',
'👥',
'📥',
'🔖',
'❌',
'✅',
'\u00b7',
'\u2022',
'\u2010',
'\u2013',
'\u2014',
'\u2212',
'|',
],
attributes: {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
],
input: ['placeholder'],
},
directives: ['v-text'],
},
],
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-root-v-if': 'warn',
'vue/no-static-inline-styles': [
'error',
{
allowBinding: false,
},
],
'vue/no-template-target-blank': [
'error',
{
allowReferrer: false,
enforceDynamicLinks: 'always',
},
],
'vue/no-required-prop-with-default': [
'error',
{
autofix: false,
},
],
'vue/no-this-in-before-route-enter': 'error',
'vue/no-undef-components': [
'error',
{
ignorePatterns: [
'^woot-',
'^fluent-',
'^multiselect',
'^router-link',
'^router-view',
'^ninja-keys',
'^FormulateForm',
'^FormulateInput',
'^highlightjs',
],
},
],
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/no-useless-v-bind': [
'error',
{
ignoreIncludesComment: false,
ignoreStringEscape: false,
},
],
'vue/no-v-text': 'error',
'vue/padding-line-between-blocks': ['error', 'always'],
'vue/prefer-separate-static-class': 'error',
'vue/require-explicit-slots': 'error',
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
ignorePublicMembers: false,
unreferencedOptions: [],
},
],
'vue/max-attributes-per-line': [ 'vue/max-attributes-per-line': [
'error', 'error',
{ {
@@ -231,25 +54,20 @@ module.exports = {
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'], 'import/extensions': ['off'],
'no-console': 'error', 'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
}, },
settings: { settings: {
'vue-i18n': { 'import/resolver': {
localeDir: './app/javascript/*/i18n/**.json', webpack: {
config: 'config/webpack/resolve.js',
},
}, },
}, },
env: { env: {
browser: true, browser: true,
jest: true,
node: true, node: true,
}, },
globals: { globals: {
bus: true, bus: true,
vi: true,
}, },
}; };
-2
View File
@@ -1,2 +0,0 @@
## All enterprise related files should be reviewed by sojan before merging
/enterprise/* @sojan-official
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

-45
View File
@@ -1,45 +0,0 @@
## github action to check deployment success
## curl the deployment url and check for 200 status
## deployment url will be of the form chatwoot-pr-<pr_number>.herokuapp.com
name: Deploy Check
on:
pull_request:
jobs:
deployment_check:
name: Check Deployment
runs-on: ubuntu-latest
steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Print Deployment URL
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
- name: Check Deployment Status
run: |
max_attempts=10
attempt=1
status_code=0
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..."
sleep 600
while [ $attempt -le $max_attempts ]; do
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
status_code=$(echo $response | head -n 1)
if [ $status_code -eq 200 ]; then
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then
echo "Deployment successful"
exit 0
else
echo "Deployment status unknown, retrying in 3 minutes..."
sleep 180
fi
else
echo "Waiting for review app to be ready, retrying in 3 minutes..."
sleep 180
attempt=$((attempt + 1))
fi
done
echo "Deployment failed after $max_attempts attempts"
exit 1
fi
-41
View File
@@ -1,41 +0,0 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install --frozen-lockfile
- name: Run eslint
run: pnpm run eslint
- name: Run frontend tests with coverage
run: |
mkdir -p coverage
pnpm run test:coverage
+8
View File
@@ -25,5 +25,13 @@ jobs:
with: with:
issue-inactive-days: '30' issue-inactive-days: '30'
issue-lock-reason: 'resolved' issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30' pr-inactive-days: '30'
pr-lock-reason: 'resolved' pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Check for log lines and calculate percentage - name: Check for log lines and calculate percentage
run: | run: |
+3 -3
View File
@@ -2,7 +2,7 @@
# # # #
# # Linux nightly installer action # # Linux nightly installer action
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
@@ -16,7 +16,7 @@ on:
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-20.04
steps: steps:
- name: get installer - name: get installer
@@ -45,7 +45,7 @@ jobs:
# curl http://localhost:3000/api # curl http://localhost:3000/api
- name: Upload chatwoot setup log file as an artifact - name: Upload chatwoot setup log file as an artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: chatwoot-setup-log-file name: chatwoot-setup-log-file
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v1
@@ -19,6 +19,5 @@ jobs:
- name: Build the Codespace Base Image - name: Build the Codespace Base Image
run: | run: |
docker-compose -f .devcontainer/docker-compose.yml build base docker build . -t ghcr.io/chatwoot/chatwoot_codespace:latest -f .devcontainer/Dockerfile.base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker push ghcr.io/chatwoot/chatwoot_codespace:latest docker push ghcr.io/chatwoot/chatwoot_codespace:latest
-140
View File
@@ -1,140 +0,0 @@
# #
# # This action will publish Chatwoot EE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot EE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+20 -102
View File
@@ -5,7 +5,6 @@
# # # #
name: Publish Chatwoot CE docker images name: Publish Chatwoot CE docker images
on: on:
push: push:
branches: branches:
@@ -15,30 +14,20 @@ on:
- v* - v*
workflow_dispatch: workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs: jobs:
build: build:
strategy: runs-on: ubuntu-latest
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env: env:
GIT_REF: ${{ github.head_ref || github.ref_name }} GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v2
- name: Prepare - name: Set up QEMU
run: | uses: docker/setup-qemu-action@v1
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
@@ -49,97 +38,26 @@ jobs:
run: | run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: Set Docker Tags - name: set docker tag
run: | run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
fi
- name: Set up QEMU - name: replace docker tag if master
uses: docker/setup-qemu-action@v3 if: github.ref_name == 'master'
run: |
- name: Set up Docker Buildx echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v1
uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest - name: Build and push
id: build uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
platforms: ${{ matrix.platform }} platforms: linux/amd64
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: true
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true tags: ${{ env.DOCKER_TAG }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+44 -39
View File
@@ -1,3 +1,9 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
on: on:
push: push:
@@ -9,13 +15,13 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: postgres:15.3
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ports: ports:
@@ -35,48 +41,47 @@ jobs:
options: --entrypoint redis-server options: --entrypoint redis-server
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v4 with:
with: ref: ${{ github.event.pull_request.head.ref }}
ref: ${{ github.event.pull_request.head.ref }} repository: ${{ github.event.pull_request.head.repo.full_name }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 23 node-version: 20
cache: 'pnpm' cache: yarn
- name: Install pnpm dependencies - name: yarn
run: pnpm i run: yarn install
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
rm -rf enterprise rm -rf enterprise
rm -rf spec/enterprise rm -rf spec/enterprise
- name: Create database - name: Create database
run: bundle exec rake db:create run: bundle exec rake db:create
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run frontend tests - name: yarn check-files
run: pnpm run test:coverage run: yarn install --check-files
# Run rails tests # Run rails tests
- name: Run backend tests - name: Run backend tests
run: | run: |
bundle exec rspec --profile=10 --format documentation bundle exec rspec --profile=10 --format documentation
env: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload rails log folder - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: rails-log-folder name: rails-log-folder
path: log path: log
@@ -0,0 +1,84 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v3
with:
node-version: 20
cache: yarn
- name: yarn
run: yarn install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v3
if: always()
with:
name: rails-log-folder
path: log
+9 -15
View File
@@ -7,10 +7,10 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -19,29 +19,23 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v4 - uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 20
cache: 'pnpm' cache: 'yarn'
- name: pnpm - name: yarn
run: pnpm install run: yarn install
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
rm -rf enterprise rm -rf enterprise
rm -rf spec/enterprise rm -rf spec/enterprise
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile - name: Run asset compile
run: bundle exec rake assets:precompile run: bundle exec rake assets:precompile
env: env:
RAILS_ENV: production NODE_OPTIONS: --openssl-legacy-provider
- name: Size Check - name: Size Check
run: pnpm run size run: yarn run size
-40
View File
@@ -1,40 +0,0 @@
name: Test Docker Build
on:
pull_request:
branches:
- develop
- master
workflow_dispatch:
jobs:
test-build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -19
View File
@@ -32,16 +32,6 @@ master.key
public/uploads public/uploads
public/packs* public/packs*
public/assets/administrate*
public/assets/action*.js
public/assets/activestorage*.js
public/assets/trix*
public/assets/belongs_to*.js
public/assets/manifest*.js
public/assets/manifest*.js
public/assets/*.js.gz
public/assets/secretField*
public/assets/.sprockets-manifest-*.json
# VIM files # VIM files
*.swp *.swp
@@ -73,7 +63,6 @@ test/cypress/videos/*
#ignore files under .vscode directory #ignore files under .vscode directory
.vscode .vscode
.cursor
# yalc for local testing # yalc for local testing
.yalc .yalc
@@ -86,11 +75,4 @@ yalc.lock
yarn-debug.log* yarn-debug.log*
.yarn-integrity .yarn-integrity
# Vite Ruby /storybook-static
/public/vite*
# Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files
*.local
# Claude.ai config file
CLAUDE.md
+3 -3
View File
@@ -4,8 +4,8 @@
# lint js and vue files # lint js and vue files
npx --no-install lint-staged npx --no-install lint-staged
# lint only staged ruby files that still exist (not deleted) # lint only staged ruby files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# stage rubocop changes to files # stage rubocop changes to files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true git diff --name-only --cached | xargs git add
+70 -23
View File
@@ -2,8 +2,7 @@ require:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
- ./rubocop/use_from_email.rb inherit_from: .rubocop_todo.yml
- ./rubocop/custom_cop_location.rb
Layout/LineLength: Layout/LineLength:
Max: 150 Max: 150
@@ -13,8 +12,7 @@ Metrics/ClassLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
Metrics/MethodLength:
Max: 19
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@@ -52,7 +50,6 @@ Lint/OrAssignmentToConstant:
Exclude: Exclude:
- 'lib/redis/config.rb' - 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Max: 30
Exclude: Exclude:
- spec/**/* - spec/**/*
- '**/routes.rb' - '**/routes.rb'
@@ -105,31 +102,84 @@ RSpec/FactoryBot/SyntaxMethods:
Enabled: false Enabled: false
Naming/VariableNumber: Naming/VariableNumber:
Enabled: false Enabled: false
Naming/MemoizedInstanceVariableName: Metrics/MethodLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Rails/CreateTableWithTimestamps:
Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/builders/account_builder.rb' - 'app/builders/account_builder.rb'
- 'app/models/attachment.rb' - 'app/models/attachment.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize: Metrics/AbcSize:
Max: 26
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Metrics/CyclomaticComplexity:
Max: 7
Exclude:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Rails/ReversibleMigration:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161025070645_remchannel.rb'
- 'db/migrate/20161110102609_removeinboxid.rb'
- 'db/migrate/20170519091539_add_avatar_to_fb.rb'
- 'db/migrate/20191020085608_rename_old_tables.rb'
- 'db/migrate/20191126185833_update_user_invite_foreign_key.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210513083044_remove_not_null_from_webhook_url_channel_api.rb'
Rails/BulkChangeTable:
Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb'
- 'db/migrate/20200121190901_create_account_users.rb'
- 'db/migrate/20170211092540_notnullableusers.rb'
- 'db/migrate/20170403095203_contactadder.rb'
- 'db/migrate/20170406104018_add_default_status_conv.rb'
- 'db/migrate/20170511134418_latlong.rb'
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
Rails/UniqueValidationWithoutIndex: Rails/UniqueValidationWithoutIndex:
Exclude: Exclude:
- 'app/models/channel/twitter_profile.rb' - 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb' - 'app/models/webhook.rb'
- 'app/models/contact.rb' - 'app/models/contact.rb'
- 'app/models/integrations/hook.rb' - 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Exclude: Exclude:
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
- 'db/migrate/20200606132552_create_labels.rb'
- 'db/migrate/20201027135006_create_working_hours.rb'
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
- 'db/migrate/20210114202310_create_teams.rb'
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
- 'db/migrate/20210428135041_add_campaigns.rb'
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
- 'db/migrate/20230503101201_create_sla_policies.rb' - 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet: RSpec/IndexedLet:
Enabled: false Enabled: false
@@ -137,21 +187,9 @@ RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleExpectations:
Max: 7
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14
# custom rules
UseFromEmail:
Enabled: true
Exclude:
- 'app/models/user.rb'
- 'app/models/contact.rb'
CustomCopLocation:
Enabled: true
AllCops: AllCops:
NewCops: enable NewCops: enable
Exclude: Exclude:
@@ -165,4 +203,13 @@ AllCops:
- 'config/environments/**/*' - 'config/environments/**/*'
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20200225162150_init_schema.rb'
- 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb'
- 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb'
- db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb
- db/migrate/20200331095710_add_identifier_to_contact.rb
- db/migrate/20200429082655_add_medium_to_twilio_sms.rb
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb
+287
View File
@@ -0,0 +1,287 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2019-10-23 16:47:02 +0530 using RuboCop version 0.73.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 1
Lint/DuplicateMethods:
Exclude:
- 'app/controllers/api/v1/reports_controller.rb'
# Offense count: 1
Lint/RescueException:
Exclude:
- 'app/builders/messages/message_builder.rb'
# Offense count: 4
Lint/ShadowingOuterLocalVariable:
Exclude:
- 'app/controllers/api/v1/reports_controller.rb'
# Offense count: 3
# Configuration parameters: AllowKeywordBlockArguments.
Lint/UnderscorePrefixedVariableName:
Exclude:
- 'app/models/account.rb'
- 'deploy/before_symlink.rb'
# Offense count: 18
Lint/UselessAssignment:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/facebook_indicators_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/listeners/reporting_listener.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/facebook_page.rb'
# Offense count: 14
Metrics/AbcSize:
Max: 26
# Offense count: 1
# Configuration parameters: CountComments, ExcludedMethods.
# ExcludedMethods: refine
Metrics/BlockLength:
Max: 30
# Offense count: 2
Metrics/CyclomaticComplexity:
Max: 7
# Offense count: 10
# Configuration parameters: CountComments, ExcludedMethods.
Metrics/MethodLength:
Max: 19
# Offense count: 1
Metrics/PerceivedComplexity:
Max: 8
# Offense count: 6
Naming/AccessorMethodName:
Exclude:
- 'app/builders/report_builder.rb'
- 'app/controllers/api/v1/accounts_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb'
# Offense count: 9
# Configuration parameters: EnforcedStyleForLeadingUnderscores.
# SupportedStylesForLeadingUnderscores: disallowed, required, optional
Naming/MemoizedInstanceVariableName:
Exclude:
- 'app/controllers/api/base_controller.rb'
- 'app/controllers/api/v1/conversations_controller.rb'
- 'app/controllers/api/v1/webhooks_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/models/message.rb'
- 'lib/integrations/widget/outgoing_message_builder.rb'
# Offense count: 4
# Cop supports --auto-correct.
# Configuration parameters: MaxKeyValuePairs.
Performance/RedundantMerge:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/models/message.rb'
# Offense count: 1
# Cop supports --auto-correct.
Performance/StringReplacement:
Exclude:
- 'lib/events/base.rb'
# Offense count: 4
# Configuration parameters: Prefixes.
# Prefixes: when, with, without
RSpec/ContextWording:
Exclude:
- 'spec/models/contact_spec.rb'
- 'spec/models/user_spec.rb'
# Offense count: 1
RSpec/DescribeClass:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
RSpec/DescribeSymbol:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: AllowConsecutiveOneLiners.
RSpec/EmptyLineAfterExample:
Exclude:
- 'spec/models/user_spec.rb'
# Offense count: 1
# Configuration parameters: Max.
RSpec/ExampleLength:
Exclude:
- 'spec/models/conversation_spec.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: single_line_only, single_statement_only, disallow
RSpec/ImplicitSubject:
Exclude:
- 'spec/models/user_spec.rb'
# Offense count: 7
# Configuration parameters: AggregateFailuresByDefault.
RSpec/MultipleExpectations:
Max: 7
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: not_to, to_not
RSpec/NotToNot:
Exclude:
- 'spec/mailers/confirmation_instructions_spec.rb'
# Offense count: 1
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/models/conversation_spec.rb'
# Offense count: 4
# Cop supports --auto-correct.
Rails/ActiveRecordAliases:
Exclude:
- 'app/controllers/api/v1/agents_controller.rb'
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/canned_responses_controller.rb'
- 'app/controllers/api/v1/contacts_controller.rb'
# Offense count: 2
# Cop supports --auto-correct.
Rails/BelongsTo:
Exclude:
- 'app/models/message.rb'
- 'app/models/user.rb'
# Offense count: 6
# Cop supports --auto-correct.
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/EnumHash:
Exclude:
- 'app/models/attachment.rb'
- 'app/models/conversation.rb'
- 'app/models/message.rb'
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/HasManyOrHasOneDependent:
Exclude:
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/InverseOf:
Exclude:
- 'app/models/user.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: app/controllers/**/*.rb
Rails/LexicallyScopedActionFilter:
Exclude:
- 'app/controllers/home_controller.rb'
# Offense count: 2
# Configuration parameters: Include.
# Include: app/**/*.rb, config/**/*.rb, db/**/*.rb, lib/**/*.rb
Rails/Output:
Exclude:
- 'app/bot/bot.rb'
- 'app/builders/account_builder.rb'
# Offense count: 7
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: strict, flexible
Rails/TimeZone:
Exclude:
- 'app/builders/report_builder.rb'
- 'lib/reports/update_account_identity.rb'
- 'lib/reports/update_agent_identity.rb'
- 'lib/reports/update_identity.rb'
- 'spec/models/conversation_spec.rb'
# Offense count: 8
# Cop supports --auto-correct.
# Configuration parameters: Include.
# Include: app/models/**/*.rb
Rails/Validation:
Exclude:
- 'app/models/canned_response.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/facebook_page.rb'
- 'app/models/telegram_bot.rb'
- 'app/models/user.rb'
# Offense count: 15
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle.
# SupportedStyles: nested, compact
Style/ClassAndModuleChildren:
Exclude:
- 'app/builders/messages/message_builder.rb'
- 'app/controllers/api/v1/inbox_members_controller.rb'
- 'app/models/channel/facebook_page.rb'
- 'app/models/channel/web_widget.rb'
- 'app/presenters/conversations/event_data_presenter.rb'
- 'app/services/facebook/send_reply_service.rb'
- 'lib/integrations/facebook/delivery_status.rb'
- 'lib/integrations/facebook/message_creator.rb'
- 'lib/integrations/facebook/message_parser.rb'
- 'lib/integrations/widget/incoming_message_builder.rb'
# Offense count: 4
Style/CommentedKeyword:
Exclude:
- 'app/controllers/api/v1/callbacks_controller.rb'
- 'app/controllers/api/v1/conversations/assignments_controller.rb'
- 'app/controllers/api/v1/conversations/labels_controller.rb'
- 'app/controllers/api/v1/labels_controller.rb'
# Offense count: 1
# Configuration parameters: AllowIfModifier.
Style/IfInsideElse:
Exclude:
- 'app/finders/conversation_finder.rb'
# Offense count: 1
Style/MixinUsage:
Exclude:
- 'app/bot/bot.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
Exclude:
- 'spec/**/*'
- 'app/controllers/api/v1/callbacks_controller.rb'
# Offense count: 1
# Cop supports --auto-correct.
# Configuration parameters: EnforcedStyle.
# SupportedStyles: implicit, explicit
Style/RescueStandardError:
Exclude:
- 'app/models/channel/facebook_page.rb'
+1 -1
View File
@@ -1 +1 @@
3.3.3 3.2.2
-1
View File
@@ -283,4 +283,3 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css' - 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss' - 'app/javascript/shared/assets/stylesheets/*.scss'
- 'app/javascript/dashboard/assets/scss/_woot.scss'
+56
View File
@@ -0,0 +1,56 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
+48
View File
@@ -0,0 +1,48 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function() {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
+36 -41
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.3.3' ruby '3.2.2'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7.0.8.4' gem 'rails', '~> 7.0.8.0'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@@ -15,7 +15,7 @@ gem 'browser'
gem 'hashie' gem 'hashie'
gem 'jbuilder' gem 'jbuilder'
gem 'kaminari' gem 'kaminari'
gem 'responders', '>= 3.1.1' gem 'responders'
gem 'rest-client' gem 'rest-client'
gem 'telephone_number' gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
@@ -39,8 +39,6 @@ gem 'rack-attack', '>= 6.7.0'
gem 'down' gem 'down'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
gem 'net-smtp', '~> 0.3.4'
# Prevent CSV injection # Prevent CSV injection
gem 'csv-safe' gem 'csv-safe'
@@ -49,7 +47,7 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively # original gem isn't maintained actively
# we wanted updated version of faraday which is a dependency for slack-ruby-client # we wanted updated version of faraday which is a dependency for slack-ruby-client
gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', branch: 'chatwoot', require: false
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', require: false
gem 'image_processing' gem 'image_processing'
##-- gems for database --# ##-- gems for database --#
@@ -61,24 +59,24 @@ gem 'redis-namespace'
gem 'activerecord-import' gem 'activerecord-import'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails'
gem 'foreman' gem 'foreman'
gem 'puma' gem 'puma'
gem 'vite_rails' gem 'webpacker'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
##--- gems for authentication & authorization ---## ##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4' gem 'devise'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3' gem 'devise_token_auth'
# authorization # authorization
gem 'jwt' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.20.1' gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage', '>= 1.0.3' gem 'administrate-field-active_storage'
gem 'administrate-field-belongs_to_search', '>= 0.9.0' gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---## ##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/ # https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -94,14 +92,14 @@ gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.5.2' gem 'slack-ruby-client', '~> 2.0.0'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2'
gem 'grpc' gem 'grpc'
# Translate integrations # Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version # 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem # this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3', '>= 0.7.0' gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---# ##-- apm and error monitoring ---#
# loaded only when environment variables are set. # loaded only when environment variables are set.
@@ -109,20 +107,20 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
gem 'ddtrace', require: false gem 'ddtrace', require: false
gem 'elastic-apm', require: false gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', require: false
gem 'scout_apm', require: false gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.19.0', require: false gem 'sentry-rails', '>= 5.11.0', require: false
gem 'sentry-ruby', require: false gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sentry-sidekiq', '>= 5.11.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '>= 7.3.1' gem 'sidekiq', '>= 7.1.3'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0' gem 'sidekiq-cron', '>= 1.10.1'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
gem 'web-push', '>= 3.0.1' gem 'web-push'
##-- geocoding / parse location from ip --## ##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/ # http://www.rubygeocoder.com/
@@ -138,7 +136,9 @@ gem 'procore-sift'
# parse email # parse email
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' # TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
# to calculate working hours # to calculate working hours
gem 'working_hours' gem 'working_hours'
@@ -154,17 +154,17 @@ gem 'stripe'
gem 'faker' gem 'faker'
# Include logrange conditionally in intializer using env variable # Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.14.0', require: false gem 'lograge', '~> 0.13.0', require: false
# worked with microsoft refresh token # worked with microsoft refresh token
gem 'omniauth-oauth2' gem 'omniauth-oauth2'
gem 'audited', '~> 5.4', '>= 5.4.1' gem 'audited', '~> 5.4', '>= 5.4.0'
# need for google auth # need for google auth
gem 'omniauth', '>= 2.1.2' gem 'omniauth'
gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'omniauth-rails_csrf_protection', '~> 1.0'
## Gems for reponse bot ## Gems for reponse bot
# adds cosine similarity to postgres using vector extension # adds cosine similarity to postgres using vector extension
@@ -173,10 +173,8 @@ gem 'pgvector'
# Convert Website HTML to Markdown # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'iso-639' # Sentiment analysis
gem 'ruby-openai' gem 'informers'
gem 'shopify_api'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
@@ -184,9 +182,6 @@ gem 'shopify_api'
group :production do group :production do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end end
group :development do group :development do
@@ -203,13 +198,13 @@ group :development do
gem 'squasher' gem 'squasher'
# profiling # profiling
gem 'rack-mini-profiler', '>= 3.2.0', require: false gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
end end
group :test do group :test do
# Cypress in rails.
gem 'cypress-on-rails'
# fast cleaning of database # fast cleaning of database
gem 'database_cleaner' gem 'database_cleaner'
# mock http calls # mock http calls
@@ -227,12 +222,12 @@ group :development, :test do
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'climate_control' gem 'climate_control'
gem 'debug', '~> 1.8' gem 'debug', '~> 1.8'
gem 'factory_bot_rails', '>= 6.4.3' gem 'factory_bot_rails'
gem 'listen' gem 'listen'
gem 'mock_redis' gem 'mock_redis'
gem 'pry-rails' gem 'pry-rails'
gem 'rspec_junit_formatter' gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.5' gem 'rspec-rails'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
+293 -333
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2024 Chatwoot Inc. Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows: Portions of this software are licensed as follows:
+3 -6
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup: setup:
gem install bundler gem install bundler
bundle install bundle install
pnpm install yarn install
db_create: db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -17,9 +17,6 @@ db_migrate:
db_seed: db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db_reset:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
db: db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
@@ -30,7 +27,7 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000 RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn: burn:
bundle && pnpm install bundle && yarn
run: run:
@if [ -f ./.overmind.sock ]; then \ @if [ -f ./.overmind.sock ]; then \
@@ -52,4 +49,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker .PHONY: setup db_create db_migrate db_seed db console server burn docker run force_run debug debug_worker
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000 backend: bin/rails s -p 3000
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695 # https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev
+2 -2
View File
@@ -1,3 +1,3 @@
backend: RAILS_ENV=test bin/rails s -p 5050 backend: RAILS_ENV=test bin/rails s -p 5050
vite: bin/vite dev frontend: bin/webpack-dev-server
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml worker: dotenv RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml
+50 -68
View File
@@ -1,27 +1,10 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/> <p align="center">
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/> <img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
___ <p align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
# Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<p align="center">
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku"> <a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/> <img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a> </a>
@@ -30,61 +13,58 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
</a> </a>
</p> </p>
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> ___
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
--- <p align="center">
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p>
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels. <img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>
### ✨ Captain AI Agent for Support
Supercharge your support with Captain, Chatwoots AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs).
### 💬 Omnichannel Support Desk Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc. ## Features
### 📚 Help center portal Chatwoot supports the following conversation channels:
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues. - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
### 🗂️ Other features And more.
#### Collaboration & Productivity Other features include:
- Private Notes and @mentions for internal team discussions.
- Labels to organize and categorize conversations.
- Keyboard Shortcuts and a Command Bar for quick navigation.
- Canned Responses to reply faster to frequently asked questions.
- Auto-Assignment to route conversations based on agent availability.
- Multi-lingual Support to serve customers in multiple languages.
- Custom Views and Filters for better inbox organization.
- Business Hours and Auto-Responders to manage response expectations.
- Teams and Automation tools for scaling support workflows.
- Agent Capacity Management to balance workload across the team.
#### Customer Data & Segmentation
- Contact Management with profiles and interaction history.
- Contact Segments and Notes for targeted communication.
- Campaigns to proactively engage customers.
- Custom Attributes for storing additional customer data.
- Pre-Chat Forms to collect user information before starting conversations.
#### Integrations
- Slack Integration to manage conversations directly from Slack.
- Dialogflow Integration for chatbot automation.
- Dashboard Apps to embed internal tools within Chatwoot.
- Shopify Integration to view and manage customer orders right within Chatwoot.
- Use Google Translate to translate messages from your customers in realtime.
- Create and manage Linear tickets within Chatwoot.
#### Reports & Insights
- Live View of ongoing conversations for real-time monitoring.
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
- CSAT Reports to measure customer satisfaction.
- Downloadable Reports for offline analysis and reporting.
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation ## Documentation
@@ -126,15 +106,17 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server. If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2023, Chatwoot Inc - Released under the MIT License.
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## Reporting a Vulnerability ## Reporting a Vulnerability
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com. If you have any questions about the process, contact security@chatwoot.com.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible. Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new) > Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
## Supported versions ## Supported versions
| Version | Supported | | Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks - Brute force attacks
- DNSSEC - DNSSEC
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new). If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks ## Thanks
+1 -1
View File
@@ -1 +1 @@
3.13.0 3.1.0
+1 -1
View File
@@ -1 +1 @@
3.2.0 2.4.0
+3 -8
View File
@@ -48,17 +48,17 @@
"size": "basic" "size": "basic"
} }
}, },
"stack": "heroku-24", "stack": "heroku-20",
"image": "heroku/ruby", "image": "heroku/ruby",
"addons": [ "addons": [
{ {
"plan": "heroku-redis:mini" "plan": "heroku-redis:mini"
}, },
{ {
"plan": "heroku-postgresql:essential-0" "plan": "heroku-postgresql:mini"
} }
], ],
"stack": "heroku-24", "stack": "heroku-20",
"buildpacks": [ "buildpacks": [
{ {
"url": "heroku/nodejs" "url": "heroku/nodejs"
@@ -72,11 +72,6 @@
"scripts": { "scripts": {
"test": "bundle exec rake test" "test": "bundle exec rake test"
} }
},
"review": {
"scripts": {
"postdeploy": "bundle exec rails db:seed"
}
} }
} }
} }
+2 -2
View File
@@ -59,7 +59,7 @@ class ContactIdentifyAction
def existing_email_contact def existing_email_contact
return if params[:email].blank? return if params[:email].blank?
@existing_email_contact ||= account.contacts.from_email(params[:email]) @existing_email_contact ||= account.contacts.find_by(email: params[:email])
end end
def existing_phone_number_contact def existing_phone_number_contact
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! @contact.save!
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached? Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)
+1 -6
View File
@@ -12,7 +12,6 @@ class ContactMergeAction
merge_conversations merge_conversations
merge_messages merge_messages
merge_contact_inboxes merge_contact_inboxes
merge_contact_notes
merge_and_remove_mergee_contact merge_and_remove_mergee_contact
end end
@base_contact @base_contact
@@ -34,10 +33,6 @@ class ContactMergeAction
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end end
def merge_contact_notes
Note.where(contact_id: @mergee_contact.id, account_id: @mergee_contact.account_id).update(contact_id: @base_contact.id)
end
def merge_messages def merge_messages
Message.where(sender: @mergee_contact).update(sender: @base_contact) Message.where(sender: @mergee_contact).update(sender: @base_contact)
end end
@@ -54,7 +49,7 @@ class ContactMergeAction
# attributes in base contact are given preference # attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.reload.destroy! @mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)]) tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes) @base_contact.update!(merged_attributes)
+1
View File
@@ -2,4 +2,5 @@
//= link administrate/application.css //= link administrate/application.css
//= link administrate/application.js //= link administrate/application.js
//= link administrate-field-active_storage/application.css //= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js //= link secretField.js
+55
View File
@@ -0,0 +1,55 @@
// eslint-disable-next-line
function prepareData(data) {
var labels = [];
var dataSet = [];
data.forEach(item => {
labels.push(item[0]);
dataSet.push(item[1]);
});
return { labels, dataSet };
}
function getChartOptions() {
var fontFamily =
'PlusJakarta,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
return {
responsive: true,
legend: { labels: { fontFamily } },
scales: {
xAxes: [
{
barPercentage: 1.26,
ticks: { fontFamily },
gridLines: { display: false },
},
],
yAxes: [
{
ticks: { fontFamily },
gridLines: { display: false },
},
],
},
};
}
// eslint-disable-next-line
function drawSuperAdminDashboard(data) {
var ctx = document.getElementById('dashboard-chart').getContext('2d');
var chartData = prepareData(data);
// eslint-disable-next-line
new Chart(ctx, {
type: 'bar',
data: {
labels: chartData.labels,
datasets: [
{
label: 'Conversations',
data: chartData.dataSet,
backgroundColor: '#1f93ff',
},
],
},
options: getChartOptions(),
});
}
@@ -25,6 +25,7 @@
@import 'components/flashes'; @import 'components/flashes';
@import 'components/form-actions'; @import 'components/form-actions';
@import 'components/main-content'; @import 'components/main-content';
@import 'components/navigation';
@import 'components/pagination'; @import 'components/pagination';
@import 'components/search'; @import 'components/search';
@import 'components/reports'; @import 'components/reports';
@@ -1,7 +1,7 @@
html { html {
background-color: $color-white; background-color: $color-white;
box-sizing: border-box; box-sizing: border-box;
font-size: 16px; font-size: 10px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -16,8 +16,8 @@
.attribute-data { .attribute-data {
float: left; float: left;
margin-bottom: $base-spacing; margin-bottom: $base-spacing;
margin-left: 1.25rem; margin-left: 2rem;
width: calc(84% - 0.625rem); width: calc(84% - 1rem);
} }
.attribute--nested { .attribute--nested {
@@ -1,15 +1,15 @@
button:not(.reset-base), button,
input[type='button']:not(.reset-base), input[type="button"],
input[type='reset']:not(.reset-base), input[type="reset"],
input[type='submit']:not(.reset-base), input[type="submit"],
.button:not(.reset-base) { .button {
appearance: none; appearance: none;
background-color: $color-woot; background-color: $color-woot;
border: 0; border: 0;
border-radius: $base-border-radius; border-radius: $base-border-radius;
color: $white; color: $white;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-block;
font-size: $font-size-small; font-size: $font-size-small;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
@@ -9,22 +9,22 @@
.field-unit__label { .field-unit__label {
float: left; float: left;
margin-left: 0.625rem; margin-left: 1rem;
text-align: right; text-align: right;
width: calc(15% - 0.625rem); width: calc(15% - 1rem);
} }
.field-unit__field { .field-unit__field {
float: left; float: left;
margin-left: 1.25rem; margin-left: 2rem;
max-width: 31.15rem; max-width: 50rem;
width: 100%; width: 100%;
} }
.field-unit--nested { .field-unit--nested {
border: $base-border; border: $base-border;
margin-left: 7.5%; margin-left: 7.5%;
max-width: 37.5rem; max-width: 60rem;
padding: $small-spacing; padding: $small-spacing;
width: 100%; width: 100%;
@@ -1,3 +1,3 @@
.form-actions { .form-actions {
margin-left: calc(15% + 1.25rem); margin-left: calc(15% + 2rem);
} }
@@ -13,10 +13,6 @@
table { table {
font-size: $font-size-small; font-size: $font-size-small;
} }
form {
margin-top: $space-two;
}
} }
.main-content__header { .main-content__header {
@@ -24,7 +20,7 @@
background-color: $color-white; background-color: $color-white;
border-bottom: 1px solid $color-border; border-bottom: 1px solid $color-border;
display: flex; display: flex;
min-height: 3.5rem; min-height: 5.6rem;
padding: $space-small $space-normal; padding: $space-small $space-normal;
} }
@@ -0,0 +1,88 @@
.logo-brand {
margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small;
text-align: left;
img {
margin-bottom: $space-smaller;
max-height: 3rem;
}
}
.navigation {
background: $white;
border-right: 1px solid $color-border;
display: flex;
flex-direction: column;
font-size: $font-size-default;
font-weight: $font-weight-medium;
height: 100%;
justify-content: flex-start;
left: 0;
margin: 0;
overflow: auto;
padding: $space-normal;
position: fixed;
top: 0;
width: 21rem;
z-index: 1023;
li {
align-items: center;
display: flex;
font-size: $font-size-small;
a {
color: $color-gray;
text-decoration: none;
}
i {
min-width: $space-medium;
}
}
hr {
margin: $space-slab;
}
}
.navigation__link {
background-color: transparent;
color: $color-gray;
display: block;
line-height: 1;
margin-bottom: $space-smaller;
padding: $space-small;
&:hover {
color: $blue;
a {
color: $blue;
}
}
&.navigation__link--active {
background-color: $color-background;
border-radius: $base-border-radius;
color: $blue;
a {
color: $blue;
}
}
}
.logout {
bottom: $space-normal;
left: $space-normal;
position: fixed;
}
.app-version {
color: $color-gray;
font-size: $font-size-small;
padding-top: $space-smaller;
}
@@ -1,7 +1,7 @@
.search { .search {
margin-left: auto; margin-left: auto;
margin-right: 1.25rem; margin-right: 2rem;
max-width: 27.5rem; max-width: 44rem;
position: relative; position: relative;
width: 100%; width: 100%;
} }
@@ -9,7 +9,8 @@
padding: 4px 12px; padding: 4px 12px;
.icon-container { .icon-container {
margin-right: 2px; margin-right: 4px;
} }
.value-container { .value-container {
@@ -1,10 +1,10 @@
// Typography // Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", $base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default; sans-serif !default;
$heading-font-family: $base-font-family !default; $heading-font-family: $base-font-family !default;
$base-font-size: 16px !default; $base-font-size: 14px !default;
$base-line-height: 1.5 !default; $base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default; $heading-line-height: 1.2 !default;
@@ -1,30 +1,30 @@
// Font sizes // Font sizes
$font-size-nano: 0.5rem; $font-size-nano: 0.8rem;
$font-size-micro: 0.675rem; $font-size-micro: 1.0rem;
$font-size-mini: 0.75rem; $font-size-mini: 1.2rem;
$font-size-small: 0.875rem; $font-size-small: 1.4rem;
$font-size-default: 1rem; $font-size-default: 1.6rem;
$font-size-medium: 1.125rem; $font-size-medium: 1.8rem;
$font-size-large: 1.375rem; $font-size-large: 2.2rem;
$font-size-big: 1.5rem; $font-size-big: 2.4rem;
$font-size-bigger: 1.75rem; $font-size-bigger: 3.0rem;
$font-size-mega: 2.125rem; $font-size-mega: 3.4rem;
$font-size-giga: 2.5rem; $font-size-giga: 4.0rem;
// spaces // spaces
$zero: 0; $zero: 0;
$space-micro: 0.125rem; $space-micro: 0.2rem;
$space-smaller: 0.25rem; $space-smaller: 0.4rem;
$space-small: 0.5rem; $space-small: 0.8rem;
$space-one: 0.675rem; $space-one: 1rem;
$space-slab: 0.75rem; $space-slab: 1.2rem;
$space-normal: 1rem; $space-normal: 1.6rem;
$space-two: 1.25rem; $space-two: 2.0rem;
$space-medium: 1.5rem; $space-medium: 2.4rem;
$space-large: 2rem; $space-large: 3.2rem;
$space-larger: 3rem; $space-larger: 4.8rem;
$space-jumbo: 4rem; $space-jumbo: 6.4rem;
$space-mega: 6.25rem; $space-mega: 10.0rem;
// font-weight // font-weight
$font-weight-feather: 100; $font-weight-feather: 100;
@@ -86,5 +86,8 @@ $swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; $swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default; $swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions // Transitions
$transition-ease-in: all 0.250s ease-in; $transition-ease-in: all 0.250s ease-in;
+6 -35
View File
@@ -2,7 +2,7 @@
class AccountBuilder class AccountBuilder
include CustomExceptions::Account include CustomExceptions::Account
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale] pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
def perform def perform
if @user.nil? if @user.nil?
@@ -15,30 +15,18 @@ class AccountBuilder
end end
[@user, @account] [@user, @account]
rescue StandardError => e rescue StandardError => e
Rails.logger.debug e.inspect puts e.inspect
raise e raise e
end end
private private
def user_full_name
# the empty string ensures that not-null constraint is not violated
@user_full_name || ''
end
def account_name
# the empty string ensures that not-null constraint is not violated
@account_name || ''
end
def validate_email def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email) address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable? if address.valid? # && !address.disposable?
true true
else else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? }) raise InvalidEmail.new(valid: address.valid?)
end end
end end
@@ -51,7 +39,7 @@ class AccountBuilder
end end
def create_account def create_account
@account = Account.create!(name: account_name, locale: I18n.locale) @account = Account.create!(name: @account_name, locale: I18n.locale)
Current.account = @account Current.account = @account
end end
@@ -76,26 +64,9 @@ class AccountBuilder
@user = User.new(email: @email, @user = User.new(email: @email,
password: user_password, password: user_password,
password_confirmation: user_password, password_confirmation: user_password,
name: user_full_name) name: @user_full_name)
@user.type = 'SuperAdmin' if @super_admin @user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end end
-54
View File
@@ -1,54 +0,0 @@
# The AgentBuilder class is responsible for creating a new agent.
# It initializes with necessary attributes and provides a perform method
# to create a user and account user in a transaction.
class AgentBuilder
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
# @param role [String] the role of the user, defaults to 'agent' if not provided.
# @param inviter [User] the user who is inviting the agent (Current.user in most cases).
# @param availability [String] the availability status of the user, defaults to 'offline' if not provided.
# @param auto_offline [Boolean] the auto offline status of the user.
pattr_initialize [:email, { name: '' }, :inviter, :account, { role: :agent }, { availability: :offline }, { auto_offline: false }]
# Creates a user and account user in a transaction.
# @return [User] the created user.
def perform
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
@user
end
private
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
user = User.from_email(email)
return user if user
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end
# Checks if the user needs confirmation.
# @return [Boolean] true if the user is persisted and not confirmed, false otherwise.
def user_needs_confirmation?
@user.persisted? && !@user.confirmed?
end
# Creates an account user linking the user to the current account.
def create_account_user
AccountUser.create!({
account_id: account.id,
user_id: @user.id,
inviter_id: inviter.id
}.merge({
role: role,
availability: availability,
auto_offline: auto_offline
}.compact))
end
end
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock! @contact_inbox.lock!
# We won't send campaigns if a conversation is already present # We won't send campaigns if a conversation is already present
raise 'Conversation already present' if @contact_inbox.reload.conversations.present? raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params) @conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
-35
View File
@@ -64,40 +64,5 @@ class ContactInboxBuilder
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_id source_id: @source_id
) )
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
retry
end
def update_old_contact_inbox
# The race condition occurs when theres a contact inbox with the
# same source ID but linked to a different contact. This can happen
# if the agent updates the contacts email or phone number, or
# if the contact is merged with another.
#
# We update the old contact inbox source_id to a random value to
# avoid disrupting the current flow. However, the root cause of
# this issue is a flaw in the contact inbox model design.
# Contact inbox is essentially tracking a session and is not
# needed for non-live chat channels.
raise ActiveRecord::RecordNotUnique unless allowed_channels?
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
return if contact_inbox.blank?
contact_inbox.update!(source_id: new_source_id)
end
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end end
end end
@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
ActiveRecord::Base.transaction(requires_new: true) do ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end end
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end end
private private
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?
@@ -99,7 +75,7 @@ class ContactInboxWithContactBuilder
def find_contact_by_email(email) def find_contact_by_email(email)
return if email.blank? return if email.blank?
account.contacts.from_email(email) account.contacts.find_by(email: email.downcase)
end end
def find_contact_by_phone_number(phone_number) def find_contact_by_phone_number(phone_number)
@@ -25,9 +25,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
build_contact_inbox build_contact_inbox
build_message build_message
end end
rescue Koala::Facebook::AuthenticationError => e rescue Koala::Facebook::AuthenticationError
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
@@ -53,23 +51,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def conversation def conversation
@conversation ||= set_conversation_based_on_inbox_config @conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
Conversation.where(conversation_params).order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_or_build_for_multiple_conversations
# If lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
last_conversation = Conversation.where(conversation_params).where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end end
def build_conversation def build_conversation
@@ -111,9 +93,6 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type, message_type: @message_type,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end
@@ -126,15 +105,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
} }
end end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params def contact_params
begin begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {} result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError => e rescue Koala::Facebook::AuthenticationError
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
raise raise
rescue Koala::Facebook::ClientError => e rescue Koala::Facebook::ClientError => e
@@ -152,6 +127,4 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
process_contact_params_result(result) process_contact_params_result(result)
end end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end end
@@ -1,178 +0,0 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,165 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false) def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo) super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end end
private private
def get_story_object_from_source_id(source_id) def attachments
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" @messaging[:message][:attachments] || {}
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def handle_error_response(response) def message_type
parsed_response = JSON.parse(response.body) @outgoing_echo ? :outgoing : :incoming
error_code = parsed_response.dig('error', 'code') end
# https://developers.facebook.com/docs/messenger-platform/error-codes def message_identifier
# Access token has expired or become invalid. message[:mid]
channel.authorization_error! if error_code == 190 end
# There was a problem scraping data from the provided link. def message_source_id
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 @outgoing_echo ? recipient_id : sender_id
if error_code == 1_609_005 end
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end end
def base_uri def save_story_id
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact
}
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end end
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
+4 -37
View File
@@ -9,11 +9,11 @@ class Messages::MessageBuilder
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@items = content_attributes&.dig(:items) @items = params.to_unsafe_h&.dig(:content_attributes, :items)
end end
def perform def perform
@@ -26,38 +26,6 @@ class Messages::MessageBuilder
private private
# Extracts content attributes from the given params.
# - Converts ActionController::Parameters to a regular hash if needed.
# - Attempts to parse a JSON string if content is a string.
# - Returns an empty hash if content is not present, if there's a parsing error, or if it's an unexpected type.
def content_attributes
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments def process_attachments
return if @attachments.blank? return if @attachments.blank?
@@ -149,8 +117,7 @@ class Messages::MessageBuilder
content_type: @params[:content_type], content_type: @params[:content_type],
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id], echo_id: @params[:echo_id]
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
end end
@@ -27,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id } params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment)) params.merge!(file_type_params(attachment))
elsif file_type == :location elsif file_type == :location
params.merge!(location_params(attachment)) params.merge!(location_params(attachment))
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
# This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(source_id)
def get_story_object_from_source_id(_source_id) k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{} {}
end end
+6 -10
View File
@@ -1,20 +1,22 @@
class NotificationBuilder class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor] pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
def perform def perform
return unless user_subscribed_to_notification?
build_notification build_notification
end end
private private
def current_user def secondary_actor
Current.user Current.user
end end
def user_subscribed_to_notification? def user_subscribed_to_notification?
notification_setting = user.notification_settings.find_by(account_id: account.id) notification_setting = user.notification_settings.find_by(account_id: account.id)
# added for the case where an assignee might be removed from the account but remains in conversation # added for the case where an assignee might be removed from the account but remains in conversation
return false if notification_setting.blank? return if notification_setting.blank?
return true if notification_setting.public_send("email_#{notification_type}?") return true if notification_setting.public_send("email_#{notification_type}?")
return true if notification_setting.public_send("push_#{notification_type}?") return true if notification_setting.public_send("push_#{notification_type}?")
@@ -23,17 +25,11 @@ class NotificationBuilder
end end
def build_notification def build_notification
# Create conversation_creation notification only if user is subscribed to it
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
account: account, account: account,
primary_actor: primary_actor, primary_actor: primary_actor,
# secondary_actor is secondary_actor if present, else current_user secondary_actor: secondary_actor
secondary_actor: secondary_actor || current_user
) )
end end
end end
+1 -32
View File
@@ -15,10 +15,7 @@ class V2::ReportBuilder
end end
def timeseries def timeseries
return send(params[:metric]) if metric_valid? send(params[:metric])
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end end
# For backward compatible with old report # For backward compatible with old report
@@ -46,21 +43,6 @@ class V2::ReportBuilder
} }
end end
def short_summary
{
conversations_count: conversations.count,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary
}
end
def bot_summary
{
bot_resolutions_count: bot_resolutions.count,
bot_handoffs_count: bot_handoffs.count
}
end
def conversation_metrics def conversation_metrics
if params[:type].equal?(:account) if params[:type].equal?(:account)
live_conversations live_conversations
@@ -71,18 +53,6 @@ class V2::ReportBuilder
private private
def metric_valid?
%w[conversations_count
incoming_messages_count
outgoing_messages_count
avg_first_response_time
avg_resolution_time reply_time
resolutions_count
bot_resolutions_count
bot_handoffs_count
reply_time].include?(params[:metric])
end
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@@ -132,7 +102,6 @@ class V2::ReportBuilder
unattended: @open_conversations.unattended.count unattended: @open_conversations.unattended.count
} }
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
metric metric
end end
end end
@@ -1,39 +0,0 @@
class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report
account.account_users.map do |account_user|
build_agent_stats(account_user)
end
end
def build_agent_stats(account_user)
user_id = account_user.user_id
{
id: user_id,
conversations_count: conversations_count[user_id] || 0,
resolved_conversations_count: resolved_count[user_id] || 0,
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
end
def group_by_key
:user_id
end
end
@@ -1,50 +0,0 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
def build
load_data
prepare_report
end
private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key
# Override this method
end
def prepare_report
# Override this method
end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end
@@ -1,54 +0,0 @@
class V2::Reports::BotMetricsBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def metrics
{
conversation_count: bot_conversations.count,
message_count: bot_messages.count,
resolution_rate: bot_resolution_rate.to_i,
handoff_rate: bot_handoff_rate.to_i
}
end
private
def bot_activated_inbox_ids
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
end
def bot_conversations
@bot_conversations ||= account.conversations.where(inbox_id: bot_activated_inbox_ids).where(created_at: range)
end
def bot_messages
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
end
def bot_resolutions_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
return 0 if bot_conversations.count.zero?
bot_resolutions_count.to_f / bot_conversations.count * 100
end
def bot_handoff_rate
return 0 if bot_conversations.count.zero?
bot_handoffs_count.to_f / bot_conversations.count * 100
end
end
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -1,21 +0,0 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -1,50 +0,0 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -1,37 +0,0 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
end
def group_by_key
'conversations.team_id'
end
end
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,46 +0,0 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -1,71 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
+7 -10
View File
@@ -2,9 +2,10 @@ class RoomChannel < ApplicationCable::Channel
def subscribed def subscribed
# TODO: should we only do ensure stream if current account is present? # TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence # for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user current_user
current_account current_account
ensure_stream
update_subscription update_subscription
broadcast_presence broadcast_presence
end end
@@ -21,12 +22,12 @@ class RoomChannel < ApplicationCable::Channel
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) } data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data }) ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
end end
def ensure_stream def ensure_stream
stream_from pubsub_token @pubsub_token = params[:pubsub_token]
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User) stream_from @pubsub_token
end end
def update_subscription def update_subscription
@@ -35,15 +36,11 @@ class RoomChannel < ApplicationCable::Channel
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id) ::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end end
def pubsub_token
@pubsub_token ||= params[:pubsub_token]
end
def current_user def current_user
@current_user ||= if params[:user_id].blank? @current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: pubsub_token).contact ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
else else
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id]) User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end end
end end
@@ -9,6 +9,7 @@ class Api::V1::Accounts::Actions::ContactMergesController < Api::V1::Accounts::B
mergee_contact: @mergee_contact mergee_contact: @mergee_contact
) )
contact_merge_action.perform contact_merge_action.perform
render json: @base_contact
end end
private private
@@ -10,18 +10,11 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
def show; end def show; end
def create def create
@agent_bot = Current.account.agent_bots.create!(permitted_params.except(:avatar_url)) @agent_bot = Current.account.agent_bots.create!(permitted_params)
process_avatar_from_url
end end
def update def update
@agent_bot.update!(permitted_params.except(:avatar_url)) @agent_bot.update!(permitted_params)
process_avatar_from_url
end
def avatar
@agent_bot.avatar.purge if @agent_bot.avatar.attached?
@agent_bot
end end
def destroy def destroy
@@ -37,10 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@agent_bot, params[:avatar_url]) if params[:avatar_url].present?
end end
end end
@@ -1,30 +1,20 @@
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index, :bulk_create] before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization before_action :check_authorization
before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
before_action :validate_limit_for_bulk_create, only: [:bulk_create] before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create]
def index def index
@agents = agents @agents = agents
end end
def create def create; end
builder = AgentBuilder.new(
email: new_agent_params['email'],
name: new_agent_params['name'],
role: new_agent_params['role'],
availability: new_agent_params['availability'],
auto_offline: new_agent_params['auto_offline'],
inviter: current_user,
account: Current.account
)
@agent = builder.perform
end
def update def update
@agent.update!(agent_params.slice(:name).compact) @agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact) @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end end
def destroy def destroy
@@ -33,30 +23,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def bulk_create
emails = params[:emails]
emails.each do |email|
builder = AgentBuilder.new(
email: email,
name: email.split('@').first,
inviter: current_user,
account: Current.account
)
begin
builder.perform
rescue ActiveRecord::RecordInvalid => e
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
end
end
# This endpoint is used to bulk create agents during onboarding
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
Current.account.custom_attributes.delete('onboarding_step')
Current.account.save!
head :ok
end
private private
def check_authorization def check_authorization
@@ -67,47 +33,50 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id]) @agent = agents.find(params[:id])
end end
def account_user_attributes def find_user
[:role, :availability, :auto_offline] @user = User.find_by(email: new_agent_params[:email])
end end
def allowed_agent_params # TODO: move this to a builder and combine the save account user method into a builder
[:name, :email, :role, :availability, :auto_offline] # ensure the account user association is also created in a single transaction
def create_user
return @user.send_confirmation_instructions if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end
def save_account_user
AccountUser.create!({
account_id: Current.account.id,
user_id: @user.id,
inviter_id: current_user.id
}.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end end
def agent_params def agent_params
params.require(:agent).permit(allowed_agent_params) params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end end
def new_agent_params def new_agent_params
# intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end end
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end end
def validate_limit_for_bulk_create
limit_available = params[:emails].count <= available_agent_count
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
end
def validate_limit def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent? render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
def available_agent_count
Current.account.usage_limits[:agents] - agents.count
end
def can_add_agent?
available_agent_count.positive?
end end
def delete_user_record(agent) def delete_user_record(agent)
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank? DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end end
end end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -6,15 +6,13 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def index def index
@portal_articles = @portal.articles @portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
set_article_count @articles_count = @all_articles.count
@articles = @articles.search(list_params)
@articles = if list_params[:category_slug].present? @articles = if list_params[:category_slug].present?
@articles.order_by_position.page(@current_page) @all_articles.order_by_position.page(@current_page).per(50)
else else
@articles.order_by_updated_at.page(@current_page) @all_articles.order_by_updated_at.page(@current_page)
end end
end end
@@ -45,19 +43,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
private private
def set_article_count
# Search the params without status and author_id, use this to
# compute mine count published draft etc
base_search_params = list_params.except(:status, :author_id)
@articles = @portal_articles.search(base_search_params)
@articles_count = @articles.count
@mine_articles_count = @articles.search_by_author(Current.user.id).count
@published_articles_count = @articles.published.count
@draft_articles_count = @articles.draft.count
@archived_articles_count = @articles.archived.count
end
def fetch_article def fetch_article
@article = @portal.articles.find(params[:id]) @article = @portal.articles.find(params[:id])
end end
@@ -68,10 +53,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title,
:locale, meta: [:title, :description,
:description, { tags: [] }]
{ tags: [] }]
) )
end end
@@ -21,6 +21,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
end end
def permitted_params def permitted_params
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end end
end end
@@ -48,7 +48,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params def category_params
params.require(:category).permit( params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id :name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
) )
end end
@@ -2,9 +2,13 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
before_action :authorize_request before_action :authorize_request
def create def create
process_create ActiveRecord::Base.transaction do
rescue StandardError => e authenticate_twilio
render_could_not_create_error(e.message) build_inbox
setup_webhooks if @twilio_channel.sms?
rescue StandardError => e
render_could_not_create_error(e.message)
end
end end
private private
@@ -13,14 +17,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authorize ::Inbox authorize ::Inbox
end end
def process_create
ActiveRecord::Base.transaction do
authenticate_twilio
build_inbox
setup_webhooks if @twilio_channel.sms?
end
end
def authenticate_twilio def authenticate_twilio
client = if permitted_params[:api_key_sid].present? client = if permitted_params[:api_key_sid].present?
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid]) Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
@@ -1,21 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index def index
# Start with all conversations for this contact @conversations = Current.account.conversations.includes(
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id) ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
end
# Apply permission-based filtering using the existing service private
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
# Only allow conversations from inboxes the user has access to def inbox_ids
inbox_ids = Current.user.assigned_inboxes.pluck(:id) if Current.user.administrator? || Current.user.agent?
conversations = conversations.where(inbox_id: inbox_ids) Current.user.assigned_inboxes.pluck(:id)
else
@conversations = conversations.order(last_activity_at: :desc).limit(20) []
end
end end
end end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -46,8 +46,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export def export
column_names = params['column_names'] column_names = params['column_names']
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] } Account::ContactsExportJob.perform_later(Current.account.id, column_names)
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
head :ok, message: I18n.t('errors.contacts.export.success') head :ok, message: I18n.t('errors.contacts.export.success')
end end
@@ -62,15 +61,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def filter def filter
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts] contacts = result[:contacts]
@contacts_count = result[:count] @contacts_count = result[:count]
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end end
def contactable_inboxes def contactable_inboxes
@@ -89,14 +83,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) @contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save! @contact.save!
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
process_avatar_from_url process_avatar
end end
end end
def update def update
@contact.assign_attributes(contact_update_params) @contact.assign_attributes(contact_update_params)
@contact.save! @contact.save!
process_avatar_from_url process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
end end
def destroy def destroy
@@ -154,7 +148,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :identifier, :email, :phone_number, :avatar, :blocked, :avatar_url, additional_attributes: {}, custom_attributes: {}) params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
end end
def contact_custom_attributes def contact_custom_attributes
@@ -163,16 +157,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.custom_attributes @contact.custom_attributes
end end
def contact_additional_attributes
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
@contact.additional_attributes
end
def contact_update_params def contact_update_params
permitted_params.except(:custom_attributes, :avatar_url) # we want the merged custom attributes not the original one
.merge({ custom_attributes: contact_custom_attributes }) permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
.merge({ additional_attributes: contact_additional_attributes })
end end
def set_include_contact_inboxes def set_include_contact_inboxes
@@ -187,7 +174,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end end
def process_avatar_from_url def process_avatar
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? ::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
@@ -14,8 +14,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
def set_agent def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id]) @agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.assignee = @agent @conversation.update_assignee(@agent)
@conversation.save!
render_agent render_agent
end end
@@ -1,28 +0,0 @@
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
def show
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
draft_message = Redis::Alfred.get(draft_redis_key)
render json: { has_draft: true, message: draft_message }
end
def update
Redis::Alfred.set(draft_redis_key, draft_message_params)
head :ok
end
def destroy
Redis::Alfred.delete(draft_redis_key)
head :ok
end
private
def draft_redis_key
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
end
def draft_message_params
params.dig(:draft_message, :message) || ''
end
end
@@ -1,6 +1,4 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
def index def index
@messages = message_finder.perform @messages = message_finder.perform
end end
@@ -13,29 +11,13 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
def update
Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform
@message = message
end
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all message.attachments.destroy_all
end end
end end
def retry
return if message.blank?
service = Messages::StatusUpdateService.new(message, 'sent')
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
end
def translate def translate
return head :ok if already_translated_content_available? return head :ok if already_translated_content_available?
@@ -65,16 +47,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
def permitted_params def permitted_params
params.permit(:id, :target_language, :status, :external_error) params.permit(:id, :target_language)
end end
def already_translated_content_available? def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present? message.translations.present? && message.translations[permitted_params[:target_language]].present?
end end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
end end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create] before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@conversations = result[:conversations] @conversations = result[:conversations]
@@ -26,12 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def attachments def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments @attachments = @conversation.attachments
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
end end
def show; end def show; end
@@ -43,19 +36,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
end end
def update
@conversation.update!(permitted_update_params)
end
def filter def filter
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end end
def mute def mute
@@ -76,26 +60,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def toggle_status def toggle_status
# FIXME: move this logic into a service object if params[:status]
if pending_to_open_by_bot?
@conversation.bot_handoff!
elsif params[:status].present?
set_conversation_status set_conversation_status
@status = @conversation.save! @status = @conversation.save!
else else
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end
assign_conversation if should_assign_conversation? assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end
def pending_to_open_by_bot?
return false unless Current.user.is_a?(AgentBot)
@conversation.status == 'pending' && params[:status] == 'open'
end
def should_assign_conversation?
@conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end end
def toggle_priority def toggle_priority
@@ -126,15 +97,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private private
def permitted_update_params
# TODO: Move the other conversation attributes to this method and remove specific endpoints for each attribute
params.permit(:priority)
end
def attachment_params
params.permit(:page)
end
def update_last_seen_on_conversation(last_seen_at, update_assignee) def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at) @conversation.update_column(:agent_last_seen_at, last_seen_at)
@@ -148,8 +110,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def assign_conversation def assign_conversation
@conversation.assignee = current_user @agent = Current.account.users.find(current_user.id)
@conversation.save! @conversation.update_assignee(@agent)
end end
def conversation def conversation
@@ -201,5 +163,3 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.assignee_id? && Current.user == @conversation.assignee @conversation.assignee_id? && Current.user == @conversation.assignee
end end
end end
Api::V1::Accounts::ConversationsController.prepend_mod_with('Api::V1::Accounts::ConversationsController')
@@ -39,8 +39,6 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type, :attribute_display_type,
:attribute_key, :attribute_key,
:attribute_model, :attribute_model,
:regex_pattern,
:regex_cue,
attribute_values: [] attribute_values: []
) )
end end
@@ -1,32 +0,0 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end

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