Compare commits

..
8337 changed files with 186743 additions and 949529 deletions
-65
View File
@@ -1,65 +0,0 @@
---
:position: before
:position_in_additional_file_patterns: before
:position_in_class: before
:position_in_factory: before
:position_in_fixture: before
:position_in_routes: before
:position_in_serializer: before
:position_in_test: before
:classified_sort: true
:exclude_controllers: true
:exclude_factories: true
:exclude_fixtures: true
:exclude_helpers: true
:exclude_scaffolds: true
:exclude_serializers: true
:exclude_sti_subclasses: false
:exclude_tests: true
:force: false
:format_markdown: false
:format_rdoc: false
:format_yard: false
:frozen: false
:grouped_polymorphic: false
:ignore_model_sub_dir: false
:ignore_unknown_models: false
:include_version: false
:show_check_constraints: false
:show_complete_foreign_keys: false
:show_foreign_keys: true
:show_indexes: true
:show_indexes_include: false
:simple_indexes: false
:sort: false
:timestamp: false
:trace: false
:with_comment: true
:with_column_comments: true
:with_table_comments: true
:position_of_column_comment: :with_name
:active_admin: false
:command:
:debug: false
:hide_default_column_types: json,jsonb,hstore
:hide_limit_column_types: integer,bigint,boolean
:timestamp_columns:
- created_at
- updated_at
:ignore_columns:
:ignore_routes:
:models: true
:routes: false
:skip_on_db_migrate: false
:target_action: :do_annotations
:wrapper:
:wrapper_close:
:wrapper_open:
:classes_default_to_s: []
:additional_file_patterns: []
:model_dir:
- app/models
- enterprise/app/models
:require: []
:root_dir:
- ''
-20
View File
@@ -1,23 +1,3 @@
---
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+145 -315
View File
@@ -1,89 +1,98 @@
version: 2.1
orbs:
node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
# Ruby CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-ruby/ for more details
#
version: 2
defaults: &defaults
working_directory: ~/build
machine:
image: ubuntu-2204:2024.05.1
resource_class: large
docker:
# specify the version you desire here
- 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:14.1
- image: cimg/redis:6.2.6
environment:
RAILS_LOG_TO_STDOUT: false
COVERAGE: true
LOG_LEVEL: warn
- RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs:
# Separate job for linting (no parallelism needed)
lint:
build:
<<: *defaults
steps:
- checkout
# Install minimal system dependencies for linting
- run:
name: Install System Dependencies
name: Configure Bundler
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
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
echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
source $BASH_ENV
gem install bundler
- run:
name: Install RVM and Ruby 3.4.4
command: |
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.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
name: Which bundler?
command: bundle -v
- run:
name: Install Application Dependencies
name: Swap node versions
command: |
source ~/.rvm/scripts/rvm
bundle install
set +e
wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
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 v16
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
# 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
# Swagger verification
- 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 --cache-folder ~/.cache/yarn
# Store yarn / webpacker cache
- save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }}
paths:
- ~/.cache/yarn
- 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
# verify swagger specification
- run:
name: Verify swagger API specification
command: |
@@ -93,285 +102,106 @@ jobs:
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
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
# Database setup
- run: yarn install --check-files
- run: bundle exec rake db:create
- run: bundle exec rake db:schema:load
# Bundle audit
- run:
name: Bundle audit
command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting
- run:
name: Rubocop
command: bundle exec rubocop --parallel
command: bundle exec rubocop
# - run:
# name: Brakeman
# command: bundle exec brakeman
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
command: yarn run eslint
# Separate job for frontend tests
frontend-tests:
<<: *defaults
steps:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run:
name: Run frontend tests (with coverage)
command: pnpm run test:coverage
- run:
name: Move coverage files if they exist
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/frontend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
steps:
- checkout
- node/install:
node-version: '24.13'
- node/install-pnpm:
version: '10.2.0'
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- 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:
name: Install System Dependencies
command: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
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:
name: Install RVM and Ruby 3.4.4
command: |
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.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
- run:
name: Install Application Dependencies
command: |
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- 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"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare
# Run backend tests (parallelized)
# Run rails tests
- run:
name: Run backend tests
command: |
mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend
# Use round-robin distribution (same as GitHub Actions) for better test isolation
# This prevents tests with similar timing from being grouped on the same runner
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
for i in "${!SPEC_FILES[@]}"; do
if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
mkdir -p coverage
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- $TESTS
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
# Store test results for better splitting in future runs
- run:
name: Run frontend tests
command: |
mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build
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:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: coverage
paths:
- codeclimate.*.json
# collect reports
- store_test_results:
path: ~/tmp/test-results
- store_artifacts:
path: ~/tmp/test-artifacts
- store_artifacts:
path: log
- run:
name: Move coverage files if they exist
command: |
if [ -d "coverage" ]; then
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
- persist_to_workspace:
root: ~/build
paths:
- coverage
# Collect coverage from all jobs
coverage:
<<: *defaults
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:
- checkout
- attach_workspace:
at: ~/build
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/frontend/lcov.info
- run:
name: List coverage directory contents
name: Download cc-test-reporter
command: |
ls -R ~/build/coverage || echo "No coverage directory"
- store_artifacts:
path: coverage
destination: coverage
build:
<<: *defaults
steps:
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: Legacy build aggregator
name: Upload coverage results to Code Climate
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
workflows:
version: 2
build:
commit:
jobs:
- lint
- frontend-tests
- backend-tests
- coverage:
- build
- upload-coverage:
requires:
- frontend-tests
- backend-tests
- build:
requires:
- lint
- coverage
- 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;
+58
View File
@@ -0,0 +1,58 @@
version: '2'
plugins:
rubocop:
enabled: false
channel: rubocop-0-73
eslint:
enabled: false
csslint:
enabled: true
scss-lint:
enabled: true
brakeman:
enabled: false
checks:
similar-code:
enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns:
- 'spec/'
- '**/specs/'
- 'db/*'
- 'bin/**/*'
- 'db/**/*'
- 'config/**/*'
- 'public/**/*'
- 'vendor/**/*'
- 'node_modules/**/*'
- 'lib/tasks/auto_annotate_models.rake'
- 'app/test-matchers.js'
- 'docs/*'
- '**/*.md'
- '**/*.yml'
- 'app/javascript/dashboard/i18n/locale'
- '**/*.stories.js'
- 'stories/'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
+2 -12
View File
@@ -2,17 +2,7 @@
# It has the dependencies already installed so that codespace will boot up fast
FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
# Do the set up required for chatwoot app
WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace
RUN yarn && gem install bundler && bundle install
+46 -72
View File
@@ -1,98 +1,72 @@
ARG VARIANT="ubuntu-22.04"
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# 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 \
groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg \
tar \
tzdata \
postgresql-client \
libpq-dev \
git \
imagemagick \
libyaml-dev \
curl \
ca-certificates \
tmux \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm
# Install rbenv and ruby for root user first
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
# Install rbenv and ruby
ARG RUBY_VERSION="3.2.2"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Set up rbenv for vscode user
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
# Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Do the set up required for chatwoot app
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app
WORKDIR /workspace
RUN chown vscode:vscode /workspace
COPY . /workspace
# set up node js, pnpm and claude code in single layer
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
&& npm cache clean --force
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# Switch to vscode user
USER vscode
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
# Copy dependency files first for better caching
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
# set up node js
RUN npm install npm@latest -g && \
npm install n -g && \
n latest
RUN npm install --global yarn
RUN yarn
+8 -17
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml",
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh",
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
"terminal.integrated.shell.linux": "/bin/zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"Shopify.ruby-lsp",
"rebornix.Ruby",
"misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
@@ -32,15 +23,15 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3036],
"forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3036": {
"label": "Vite Dev Server"
"3035": {
"label": "Webpack Dev Server"
},
"8025": {
"label": "Mailhog UI"
-18
View File
@@ -1,18 +0,0 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
+14 -11
View File
@@ -1,33 +1,35 @@
# https://github.com/microsoft/vscode-dev-containers/blob/master/containers/python-3-postgres/.devcontainer/docker-compose.yml
# https://github.com/microsoft/vscode-dev-containers/blob/master/containers/ruby-rails/.devcontainer/devcontainer.json
#
#
version: '3'
services:
app:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# Update 'VARIANT' to pick a Ruby version: https://github.com/microsoft/vscode-dev-containers/tree/main/containers/ruby
VARIANT: 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.
USER_UID: '1000'
USER_GID: '1000'
USER_UID: 1000
USER_GID: 1000
volumes:
- ..:/workspace:cached
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
db:
image: pgvector/pgvector:pg16
image: postgres:latest
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
@@ -47,7 +49,8 @@ services:
restart: unless-stopped
image: mailhog/mailhog
network_mode: service:db
volumes:
postgres-data:
redis-data:
+7 -10
View File
@@ -2,15 +2,12 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
+17 -60
View File
@@ -1,18 +1,6 @@
# 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
# 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
# Active Record Encryption keys (required for MFA/2FA functionality)
# Generate these keys by running: rails db:encryption:init
# IMPORTANT: Use different keys for each environment (development, staging, production)
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
@@ -35,9 +23,6 @@ FORCE_SSL=false
ENABLE_ACCOUNT_SIGNUP=false
# 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
# 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
@@ -92,43 +77,24 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# 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 :
# relay for Exim, Postfix, Qmail
# mailgun for Mailgun
# mandrill for Mandrill
# postmark for Postmark
# sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# 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=
MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY=
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# 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
ACTIVE_STORAGE_SERVICE=local
@@ -204,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_DSN=
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout
## https://scoutapm.com/docs/ruby/configuration
@@ -222,21 +193,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
@@ -260,26 +222,21 @@ STRIPE_WEBHOOK_SECRET=
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=
#MS OAUTH creds
# MS OAUTH creds
AZURE_APP_ID=
AZURE_APP_SECRET=
## MS Azure Tenant ID
# Set the following id to the id of your Azure 'tenant'.
# This will enable single tenant applications to work.
# If the following id is set, Chatwoot will use the Microsoft Graph API
# to send and receive emails, as that seems to be required for single
# tenant applications.
#
# https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant
AZURE_TENANT_ID=
## Advanced configurations
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain)
# The OpenAI API key and endpoint for Captain are not configured via .env.
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+12 -199
View File
@@ -2,38 +2,15 @@ module.exports = {
extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
'plugin:vue/recommended',
'plugin:storybook/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: {
ecmaVersion: 'latest',
parser: 'babel-eslint',
ecmaVersion: 2020,
sourceType: 'module',
},
plugins: ['html', 'prettier', 'babel'],
rules: {
'prettier/prettier': ['error'],
camelcase: 'off',
@@ -47,171 +24,13 @@ module.exports = {
'jsx-a11y/label-has-for': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'import/no-unresolved': 'off',
'vue/html-indent': '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': [
'error',
{
singleline: {
max: 20,
},
singleline: 20,
multiline: {
max: 1,
allowFirstLine: false,
},
},
],
@@ -228,29 +47,23 @@ module.exports = {
},
],
'vue/no-v-html': 'off',
'vue/component-definition-name-casing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
'import/resolver': {
webpack: {
config: 'config/webpack/resolve.js',
},
},
},
env: {
browser: true,
jest: true,
node: true,
},
globals: {
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

-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
-28
View File
@@ -1,28 +0,0 @@
name: Auto-assign PR to Author
on:
pull_request:
types: [opened]
jobs:
auto-assign:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Auto-assign PR to author
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
await github.rest.issues.addAssignees({
owner,
repo,
issue_number: pull_number,
assignees: [author]
});
console.log(`Assigned PR #${pull_number} to ${author}`);
-53
View File
@@ -1,53 +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:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
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
-44
View File
@@ -1,44 +0,0 @@
name: Frontend Lint & Test
on:
push:
branches:
- develop
pull_request:
branches:
- develop
permissions:
contents: read
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: 24
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
-29
View File
@@ -1,29 +0,0 @@
name: Sync GHSA advisories to Linear
on:
schedule:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
+8
View File
@@ -25,5 +25,13 @@ jobs:
with:
issue-inactive-days: '30'
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-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.
@@ -1,63 +0,0 @@
name: Log Lines Percentage Check
on:
pull_request:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
log_lines_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check for log lines and calculate percentage
run: |
# Define the log line pattern
LOG_LINE_PATTERN="Rails\.logger"
# Get the list of changed files in the pull request
CHANGED_FILES=$(git diff --name-only)
# Initialize a flag to track if any files have insufficient log lines
INSUFFICIENT_LOGS=0
for file in $CHANGED_FILES; do
if [[ $file =~ \.rb$ && ! $file =~ _spec\.rb$ ]]; then
# Count the total number of lines in the file
total_lines=$(wc -l < "$file")
# Count the number of log lines in the file
log_lines=$(grep -c "$LOG_LINE_PATTERN" "$file")
# Calculate the percentage of log lines
if [ "$total_lines" -gt 0 ]; then
percentage=$(awk "BEGIN { pc=100*${log_lines}/${total_lines}; i=int(pc); print (pc-i<0.5)?i:i+1 }")
else
percentage=0
fi
# Check if the percentage is less than 5%
if [ "$percentage" -lt 5 ]; then
echo "Error: Log lines percentage is less than 5% ($percentage%) in $file. Please add more log lines using Rails.logger statements."
INSUFFICIENT_LOGS=1
else
echo "Log lines percentage is $percentage% in $file. Code looks good!"
fi
fi
done
# If any files have insufficient log lines, fail the action
if [ "$INSUFFICIENT_LOGS" -eq 1 ]; then
exit 1
fi
+5 -8
View File
@@ -2,9 +2,9 @@
# #
# # Linux nightly installer action
# # 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.
# #
# #
# # This is set to run daily at midnight.
# #
@@ -14,12 +14,9 @@ on:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
runs-on: ubuntu-24.04
runs-on: ubuntu-20.04
steps:
- name: get installer
@@ -38,7 +35,7 @@ jobs:
run: |
sudo ./install.sh --install < input
# disabling http verify for now as http
# disabling http verify for now as http
# access to port 3000 fails in gh action env
# - name: Verify
# if: always()
@@ -48,7 +45,7 @@ jobs:
# curl http://localhost:3000/api
- name: Upload chatwoot setup log file as an artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
if: always()
with:
name: chatwoot-setup-log-file
@@ -3,16 +3,12 @@ name: Publish Codespace Base Image
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
@@ -23,5 +19,5 @@ jobs:
- name: Build the Codespace Base Image
run: |
docker compose -f .devcontainer/docker-compose.base.yml build base
docker build . -t ghcr.io/chatwoot/chatwoot_codespace:latest -f .devcontainer/Dockerfile.base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
-143
View File
@@ -1,143 +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
permissions:
contents: read
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 -105
View File
@@ -5,7 +5,6 @@
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
@@ -15,33 +14,20 @@ on:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
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 }}
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
@@ -52,97 +38,26 @@ jobs:
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: Set Docker Tags
- name: set docker tag
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
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
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
- name: Build and push
uses: docker/build-push-action@v2
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-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
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}
+45 -108
View File
@@ -1,6 +1,10 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
permissions:
contents: read
on:
push:
branches:
@@ -10,65 +14,19 @@ on:
workflow_dispatch:
jobs:
# Separate linting jobs for faster feedback
lint-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run ESLint
run: pnpm run eslint
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run frontend tests
run: pnpm run test:coverage
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
test:
runs-on: ubuntu-20.04
services:
postgres:
image: pgvector/pgvector:pg16
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
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
@@ -76,71 +34,50 @@ jobs:
--health-timeout 5s
--health-retries 5
redis:
image: redis:alpine
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- 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
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4
with:
node-version: 24
cache: 'pnpm'
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Install pnpm dependencies
run: pnpm i
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run backend tests (parallelized)
run: |
# Get all spec files and split them using round-robin distribution
# This ensures slow tests are distributed evenly across all nodes
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
- name: yarn check-files
run: yarn install --check-files
for i in "${!SPEC_FILES[@]}"; do
# Assign spec to this node if: index % total == node_index
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
TESTS="$TESTS ${SPEC_FILES[$i]}"
fi
done
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation
if [ -n "$TESTS" ]; then
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
fi
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: rspec-results-${{ matrix.ci_node_index }}
path: tmp/rspec_results.json
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: failure()
with:
name: rails-log-folder-${{ matrix.ci_node_index }}
path: log
- name: Upload rails log folder
uses: actions/upload-artifact@v3
if: always()
with:
name: rails-log-folder
path: log
-100
View File
@@ -1,100 +0,0 @@
name: Run MFA Tests
permissions:
contents: read
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
# Only run if MFA test keys are available
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
services:
postgres:
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
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
env:
RAILS_ENV: test
POSTGRES_HOST: localhost
# Active Record encryption keys required for MFA - test keys only, not for production use
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Create database
run: bundle exec rake db:create
- name: Install pgvector extension
run: |
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run MFA-related backend tests
run: |
bundle exec rspec \
spec/services/mfa/token_service_spec.rb \
spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Run MFA-related tests in user_spec
run: |
# Run specific MFA-related tests from user_spec
bundle exec rspec spec/models/user_spec.rb \
-e "two factor" \
-e "2FA" \
-e "MFA" \
-e "otp" \
-e "backup code" \
--profile=10 \
--format documentation
env:
NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test logs
uses: actions/upload-artifact@v4
if: failure()
with:
name: mfa-test-logs
path: |
log/test.log
tmp/screenshots/
+7 -24
View File
@@ -5,20 +5,12 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -27,29 +19,20 @@ jobs:
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version: 24
cache: 'pnpm'
node-version: 16
- name: pnpm
run: pnpm install
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: setup env
run: |
cp .env.example .env
- name: Run asset compile
run: bundle exec rake assets:precompile
env:
RAILS_ENV: production
- name: Size Check
run: pnpm run size
run: yarn run size
-43
View File
@@ -1,43 +0,0 @@
name: Test Docker Build
on:
pull_request:
branches:
- develop
- master
workflow_dispatch:
permissions:
contents: read
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,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+3 -31
View File
@@ -32,16 +32,6 @@ master.key
public/uploads
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
*.swp
@@ -71,6 +61,8 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
#ignore files under .vscode directory
.vscode
# yalc for local testing
.yalc
@@ -83,24 +75,4 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
# Vite Ruby
/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
# TextEditors & AI Agents config files
.vscode
.claude/settings.local.json
.cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
Procfile.worktree
/storybook-static
+3 -3
View File
@@ -4,8 +4,8 @@
# lint js and vue files
npx --no-install lint-staged
# lint only staged ruby files that still exist (not deleted)
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true
# lint only staged ruby files
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
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true
git diff --name-only --cached | xargs git add
+1 -1
View File
@@ -1 +1 @@
24.13.0
14.17.4
-7
View File
@@ -1,7 +0,0 @@
*
!configs
!configs/**
!hooks
!hooks/**
!qlty.toml
!.gitignore
-2
View File
@@ -1,2 +0,0 @@
ignored:
- DL3008
-1
View File
@@ -1 +0,0 @@
source-path=SCRIPTDIR
-8
View File
@@ -1,8 +0,0 @@
rules:
document-start: disable
quoted-strings:
required: only-when-needed
extra-allowed: ["{|}"]
key-duplicates: {}
octal-values:
forbid-implicit-octal: true
-84
View File
@@ -1,84 +0,0 @@
# This file was automatically generated by `qlty init`.
# You can modify it to suit your needs.
# We recommend you to commit this file to your repository.
#
# This configuration is used by both Qlty CLI and Qlty Cloud.
#
# Qlty CLI -- Code quality toolkit for developers
# Qlty Cloud -- Fully automated Code Health Platform
#
# Try Qlty Cloud: https://qlty.sh
#
# For a guide to configuration, visit https://qlty.sh/d/config
# Or for a full reference, visit https://qlty.sh/d/qlty-toml
config_version = "0"
exclude_patterns = [
"*_min.*",
"*-min.*",
"*.min.*",
"**/.yarn/**",
"**/*.d.ts",
"**/assets/**",
"**/bower_components/**",
"**/build/**",
"**/cache/**",
"**/config/**",
"**/db/**",
"**/deps/**",
"**/dist/**",
"**/extern/**",
"**/external/**",
"**/generated/**",
"**/Godeps/**",
"**/gradlew/**",
"**/mvnw/**",
"**/node_modules/**",
"**/protos/**",
"**/seed/**",
"**/target/**",
"**/templates/**",
"**/testdata/**",
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
]
test_patterns = [
"**/test/**",
"**/spec/**",
"**/*.test.*",
"**/*.spec.*",
"**/*_test.*",
"**/*_spec.*",
"**/test_*.*",
"**/spec_*.*",
]
[smells]
mode = "comment"
[smells.boolean_logic]
threshold = 4
[smells.file_complexity]
threshold = 66
enabled = true
[smells.return_statements]
threshold = 4
[smells.nested_control_flow]
threshold = 4
[smells.function_parameters]
threshold = 4
[smells.function_complexity]
threshold = 5
[smells.duplication]
enabled = true
threshold = 20
[[source]]
name = "default"
default = true
+87 -219
View File
@@ -1,101 +1,67 @@
plugins:
require:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
inherit_from: .rubocop_todo.yml
Layout/LineLength:
Max: 150
Metrics/ClassLength:
Max: 175
Max: 125
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
RSpec/ExampleLength:
Max: 50
Max: 25
Style/Documentation:
Enabled: false
Style/ExponentialNotation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SymbolArray:
Enabled: false
Style/OpenStructUse:
Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb'
Style/GlobalVars:
Exclude:
- 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb'
- 'lib/global_config.rb'
Style/ClassVars:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper:
Exclude:
- 'app/drops/base_drop.rb'
Lint/SymbolConversion:
Enabled: false
Lint/EmptyBlock:
Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant:
Exclude:
- 'lib/redis/config.rb'
Metrics/BlockLength:
Max: 30
Exclude:
- spec/**/*
- '**/routes.rb'
- 'config/environments/*'
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
- enterprise/app/helpers/captain/chat_response_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -105,117 +71,128 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Rails/InverseOf:
Exclude:
- enterprise/app/models/captain/assistant.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- app/models/canned_response.rb
- app/models/telegram_bot.rb
- enterprise/app/models/captain_inbox.rb
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:
- 'config/application.rb'
- 'config/initializers/monkey_patches/*'
Style/MapToHash:
Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never
RSpec/NestedGroups:
Enabled: true
Max: 4
RSpec/MessageSpies:
Enabled: false
RSpec/StubbedMock:
Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber:
Enabled: false
Naming/MemoizedInstanceVariableName:
Metrics/MethodLength:
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:
Exclude:
- 'app/builders/account_builder.rb'
- 'app/models/attachment.rb'
- 'app/models/message.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize:
Max: 26
Exclude:
- '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:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
Rails/RenderInline:
Exclude:
- 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
Rails/ThreeStateBooleanColumn:
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'
RSpec/IndexedLet:
Enabled: false
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleExpectations:
Max: 7
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14
# custom rules
UseFromEmail:
Enabled: true
Exclude:
- 'app/models/user.rb'
- 'app/models/contact.rb'
CustomCopLocation:
Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops:
NewCops: enable
Exclude:
@@ -229,122 +206,13 @@ AllCops:
- 'config/environments/**/*'
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
FactoryBot/SyntaxMethods:
Enabled: false
# Disable new rules causing errors
Layout/LeadingCommentSpace:
Enabled: false
Style/ReturnNilInPredicateMethodDefinition:
Enabled: false
Style/RedundantParentheses:
Enabled: false
Performance/StringIdentifierArgument:
Enabled: false
Layout/EmptyLinesAroundExceptionHandlingKeywords:
Enabled: false
Lint/LiteralAsCondition:
Enabled: false
Style/RedundantReturn:
Enabled: false
Layout/SpaceAroundOperators:
Enabled: false
Rails/EnvLocal:
Enabled: false
Rails/WhereRange:
Enabled: false
Lint/UselessConstantScoping:
Enabled: false
Style/MultipleComparison:
Enabled: false
Bundler/OrderedGems:
Enabled: false
RSpec/ExampleWording:
Enabled: false
RSpec/ReceiveMessages:
Enabled: false
FactoryBot/AssociationStyle:
Enabled: false
Rails/EnumSyntax:
Enabled: false
Lint/RedundantTypeConversion:
Enabled: false
# Additional rules to disable
Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: true
Style/SafeNavigationChainLength:
Enabled: false
Lint/SafeNavigationConsistency:
Enabled: false
Lint/CopDirectiveSyntax:
Enabled: false
# Final set of rules to disable
FactoryBot/ExcessiveCreateList:
Enabled: false
RSpec/MissingExpectationTargetMethod:
Enabled: false
Performance/InefficientHashSearch:
Enabled: false
Style/RedundantSelfAssignmentBranch:
Enabled: false
Style/YAMLFileRead:
Enabled: false
Layout/ExtraSpacing:
Enabled: false
Style/RedundantFilterChain:
Enabled: false
Performance/MapMethodChain:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
Style/SuperArguments:
Enabled: false
# Final remaining rules to disable
Rails/Delegate:
Enabled: false
Style/CaseLikeIf:
Enabled: false
FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
- '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.4.4
3.2.2
-1
View File
@@ -283,4 +283,3 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss'
- 'app/javascript/dashboard/assets/scss/_woot.scss'
+44
View File
@@ -0,0 +1,44 @@
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',
],
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', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};
+44
View File
@@ -0,0 +1,44 @@
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 FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
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$/,
},
},
};
-1
View File
@@ -1 +0,0 @@
../../AGENTS.md
-118
View File
@@ -1,118 +0,0 @@
# Chatwoot Development Guidelines
## Build / Test / Lint
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Code Style
- **Ruby**: Follow RuboCop rules (150 character max line length)
- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended)
- **Vue Components**: Use PascalCase
- **Events**: Use camelCase
- **I18n**: No bare strings in templates; use i18n
- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`)
- **Models**: Validate presence/uniqueness, add proper indexes
- **Type Safety**: Use PropTypes in Vue, strong params in Rails
- **Naming**: Use clear, descriptive names with consistent casing
- **Vue API**: Always use Composition API with `<script setup>` at the top
## Styling
- **Tailwind Only**:
- Do not write custom CSS
- Do not use scoped CSS
- Do not use inline styles
- Always use Tailwind utility classes
- **Colors**: Refer to `tailwind.config.js` for color definitions
## General Guidelines
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific
- **Translations**:
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles
## Enterprise Edition Notes
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
- Follow the Enterprise development practices documented here:
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
Practical checklist for any change impacting core logic or public APIs
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
- If adding new endpoints, services, or models, consider whether Enterprise needs:
- An override (e.g., `enterprise/app/...`), or
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+45 -97
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.4.4'
ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1'
gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 7.0.5.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -15,14 +15,12 @@ gem 'browser'
gem 'hashie'
gem 'jbuilder'
gem 'kaminari'
gem 'responders', '>= 3.1.1'
gem 'responders'
gem 'rest-client'
gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -35,18 +33,12 @@ gem 'liquid'
gem 'commonmarker'
# Validate Data against JSON Schema
gem 'json_schemer'
# used in swagger build
gem 'json_refs'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack', '>= 6.7.0'
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
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
gem 'csv-safe'
@@ -55,12 +47,9 @@ gem 'aws-sdk-s3', require: false
# original gem isn't maintained actively
# 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 'google-cloud-storage', '>= 1.48.0', require: false
gem 'google-cloud-storage', require: false
gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --#
gem 'groupdate'
gem 'pg'
@@ -69,33 +58,24 @@ gem 'redis-namespace'
# super fast record imports in bulk
gem 'activerecord-import'
gem 'searchkick'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'vite_rails'
gem 'puma'
gem 'webpacker'
# metrics on heroku
gem 'barnes'
##--- 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_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
gem 'devise_token_auth'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
gem 'administrate'
gem 'administrate-field-active_storage'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
@@ -104,44 +84,42 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.7.0'
gem 'slack-ruby-client', '~> 2.0.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# 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 ---#
# loaded only when environment variables are set.
# ref application.rb
gem 'datadog', '~> 2.0', require: false
gem 'ddtrace', require: false
gem 'elastic-apm', 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 'sentry-rails', '>= 5.19.0', require: false
gem 'sentry-rails', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
gem 'sentry-sidekiq', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
gem 'sidekiq-cron'
##-- Push notification service --##
gem 'fcm'
gem 'web-push', '>= 3.0.1'
gem 'web-push'
##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/
@@ -157,7 +135,9 @@ gem 'procore-sift'
# parse email
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
gem 'working_hours'
@@ -166,50 +146,24 @@ gem 'working_hours'
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe', '~> 18.0'
gem 'stripe'
## - helper gems --##
## to populate db with sample data
gem 'faker'
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.14.0', require: false
gem 'lograge', '~> 0.12.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.4', '>= 5.4.1'
gem 'audited', '~> 5.3'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
# adds cosine similarity to postgres using vector extension
gem 'neighbor'
gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.12.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
### Gems required only in specific deployment environments ###
##############################################################
@@ -217,38 +171,35 @@ gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
# for heroku autoscaling
gem 'judoscale-rails', require: false
gem 'judoscale-sidekiq', require: false
end
group :development do
gem 'annotaterb'
gem 'annotate'
gem 'bullet'
gem 'letter_opener'
gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1'
gem 'web-console'
# used in swagger build
gem 'json_refs'
# When we want to squash migrations
gem 'squasher'
# profiling
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'rack-mini-profiler', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
gem 'tidewave'
end
group :test do
# Cypress in rails.
gem 'cypress-on-rails'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
gem 'webmock'
# test profiling
gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end
group :development, :test do
@@ -259,22 +210,19 @@ group :development, :test do
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'debug', '~> 1.8'
gem 'factory_bot_rails', '>= 6.4.3'
gem 'factory_bot_rails'
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.5'
gem 'rspec-rails'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'simplecov', '0.17.1', require: false
gem 'spring'
gem 'spring-watcher-listen'
end
+389 -678
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2017-2026 Chatwoot Inc.
Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows:
+3 -31
View File
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
setup:
gem install bundler
bundle install
pnpm install
yarn install
db_create:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
@@ -17,9 +17,6 @@ db_migrate:
db_seed:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:seed
db_reset:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:reset
db:
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:chatwoot_prepare
@@ -30,37 +27,12 @@ server:
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
burn:
bundle && pnpm install
bundle && yarn
run:
@if [ -f ./.overmind.sock ]; then \
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
else \
overmind start -f Procfile.dev; \
fi
force_run:
@echo "Cleaning up Overmind processes..."
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
debug_worker:
overmind connect worker
docker:
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 force_run_tunnel debug debug_worker
.PHONY: setup db_create db_migrate db_seed db console server burn docker run
+1 -1
View File
@@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
frontend: bin/webpack-dev-server
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
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
vite: bin/vite dev
worker: RAILS_ENV=test dotenv bundle exec sidekiq -C config/sidekiq.yml
frontend: bin/webpack-dev-server
worker: dotenv RAILS_ENV=test bundle exec sidekiq -C config/sidekiq.yml
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+51 -68
View File
@@ -1,26 +1,10 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/>
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
<p align="center">
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/brand.svg" alt="Woot-logo" width="240" />
___
# Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<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 align="center">Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.</p>
</p>
<p>
<p align="center">
<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"/>
</a>
@@ -29,61 +13,58 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
</a>
</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
- 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.
Other features include:
- **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
@@ -119,21 +100,23 @@ Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app.
### Other deployment options
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security
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.
## Contributors
## 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>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2023, Chatwoot Inc - Released under the MIT License.
-3
View File
@@ -2,8 +2,5 @@
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application'
# Load Enterprise Edition rake tasks if they exist
enterprise_tasks_path = Rails.root.join('enterprise/tasks_railtie.rb').to_s
require enterprise_tasks_path if File.exist?(enterprise_tasks_path)
Rails.application.load_tasks
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## 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.
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
| Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks
- 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
+1 -1
View File
@@ -1 +1 @@
4.16.1
2.17.0
+1 -1
View File
@@ -1 +1 @@
3.5.0
2.3.0
+3 -15
View File
@@ -36,10 +36,6 @@
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
}
},
"formation": {
@@ -52,21 +48,18 @@
"size": "basic"
}
},
"stack": "heroku-24",
"stack": "heroku-20",
"image": "heroku/ruby",
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
"plan": "heroku-postgresql:mini"
}
],
"stack": "heroku-24",
"stack": "heroku-20",
"buildpacks": [
{
"url": "heroku/nodejs"
},
{
"url": "heroku/ruby"
}
@@ -76,11 +69,6 @@
"scripts": {
"test": "bundle exec rake test"
}
},
"review": {
"scripts": {
"postdeploy": "bundle exec rails db:seed"
}
}
}
}
+3 -11
View File
@@ -6,7 +6,6 @@
# We don't want to update the name of the identified original contact.
class ContactIdentifyAction
include UrlHelper
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform
@@ -60,7 +59,7 @@ class ContactIdentifyAction
def existing_email_contact
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
def existing_phone_number_contact
@@ -104,15 +103,8 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed?
enqueue_avatar_job
end
def enqueue_avatar_job
return unless params[:avatar_url].present? && !@contact.avatar.attached?
return unless url_valid?(params[:avatar_url])
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url])
@contact.save!
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def merge_contact(base_contact, merge_contact)
+1 -6
View File
@@ -12,7 +12,6 @@ class ContactMergeAction
merge_conversations
merge_messages
merge_contact_inboxes
merge_contact_notes
merge_and_remove_mergee_contact
end
@base_contact
@@ -34,10 +33,6 @@ class ContactMergeAction
Conversation.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
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
Message.where(sender: @mergee_contact).update(sender: @base_contact)
end
@@ -54,7 +49,7 @@ class ContactMergeAction
# attributes in base contact are given preference
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,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
+1
View File
@@ -2,4 +2,5 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.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(),
});
}
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.textContent = '•'.repeat(10);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -25,8 +25,7 @@
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/navigation';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';
@@ -24,6 +24,14 @@ select {
font-size: $base-font-size;
}
input,
select,
textarea {
display: block;
font-family: $base-font-family;
font-size: 16px;
}
[type="color"],
[type="date"],
[type="datetime-local"],
@@ -43,7 +51,6 @@ textarea {
background-color: $white;
border: $base-border;
border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em;
transition: border-color $base-duration $base-timing;
width: 100%;
@@ -1,7 +1,7 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
font-size: 10px;
-webkit-font-smoothing: antialiased;
}
@@ -4,8 +4,8 @@
float: left;
margin-bottom: $base-spacing;
margin-top: 0.25em;
text-align: left;
width: calc(16% - 1rem);
text-align: right;
width: calc(15% - 1rem);
}
.preserve-whitespace {
@@ -16,8 +16,8 @@
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
margin-left: 2rem;
width: calc(85% - 1rem);
}
.attribute--nested {
@@ -1,16 +1,16 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
button,
input[type="button"],
input[type="reset"],
input[type="submit"],
.button {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
font-size: $font-size-small;
display: inline-block;
font-size: $font-size-default;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
line-height: 1;
@@ -46,25 +46,17 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
flex: 1;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
button {
margin-left: 5px;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
@@ -9,22 +9,22 @@
.field-unit__label {
float: left;
margin-left: 0.625rem;
margin-left: 1rem;
text-align: right;
width: calc(15% - 0.625rem);
width: calc(15% - 1rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
margin-left: 2rem;
max-width: 50rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
max-width: 60rem;
padding: $small-spacing;
width: 100%;
@@ -1,3 +1,3 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
margin-left: calc(15% + 2rem);
}
@@ -1,22 +1,13 @@
.main-content {
font-size: $font-size-default;
left: 21rem;
left: 23rem;
position: absolute;
right: 0;
top: 0;
}
.main-content__body {
font-size: $font-size-small;
padding: $space-two;
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
@@ -24,12 +15,12 @@
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
min-height: 5.6rem;
padding: $space-small $space-normal;
}
.main-content__page-title {
font-size: $font-size-medium;
font-size: $font-size-large;
font-weight: $font-weight-medium;
margin-right: auto;
}
@@ -0,0 +1,78 @@
.logo-brand {
margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small;
text-align: center;
}
.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: 23rem;
z-index: 1023;
li {
align-items: center;
display: flex;
a {
color: $color-gray;
text-decoration: none;
}
i {
min-width: $space-medium;
}
}
}
.navigation__link {
background-color: transparent;
color: $color-gray;
display: block;
line-height: 1;
margin-bottom: $space-smaller;
padding: $space-one;
&: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 {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
margin-right: 2rem;
max-width: 44rem;
position: relative;
width: 100%;
}
@@ -1,23 +0,0 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 2px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}
@@ -1,10 +1,10 @@
// 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",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-font-size: 10px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
@@ -1,30 +1,30 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
$font-size-nano: 0.8rem;
$font-size-micro: 1.0rem;
$font-size-mini: 1.2rem;
$font-size-small: 1.4rem;
$font-size-default: 1.6rem;
$font-size-medium: 1.8rem;
$font-size-large: 2.2rem;
$font-size-big: 2.4rem;
$font-size-bigger: 3.0rem;
$font-size-mega: 3.4rem;
$font-size-giga: 4.0rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
$space-micro: 0.2rem;
$space-smaller: 0.4rem;
$space-small: 0.8rem;
$space-one: 1rem;
$space-slab: 1.2rem;
$space-normal: 1.6rem;
$space-two: 2.0rem;
$space-medium: 2.4rem;
$space-large: 3.2rem;
$space-larger: 4.8rem;
$space-jumbo: 6.4rem;
$space-mega: 10.0rem;
// font-weight
$font-weight-feather: 100;
@@ -78,6 +78,11 @@ $conv-header-height: 4rem;
$inbox-thumb-size: 4.8rem;
// Spinner
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
@@ -86,5 +91,8 @@ $swift-ease-out-duration: .4s !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;
// Ionicons
$ionicons-font-path: '~ionicons/fonts';
// Transitions
$transition-ease-in: all 0.250s ease-in;
+10 -19
View File
@@ -2,7 +2,7 @@
class AccountBuilder
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
if @user.nil?
@@ -15,24 +15,19 @@ class AccountBuilder
end
[@user, @account]
rescue StandardError => e
Rails.logger.debug e.inspect
puts e.inspect
raise e
end
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
Account::SignUpEmailValidationService.new(@email).perform
address = ValidEmail2::Address.new(@email)
if address.valid? # && !address.disposable?
true
else
raise InvalidEmail.new(valid: address.valid?)
end
end
def validate_user
@@ -44,11 +39,7 @@ class AccountBuilder
end
def create_account
@account = Account.create!(
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
@account = Account.create!(name: @account_name, locale: I18n.locale)
Current.account = @account
end
@@ -73,7 +64,7 @@ class AccountBuilder
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: user_full_name)
name: @user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user.save!
-73
View File
@@ -1,73 +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
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
class LimitExceededError < StandardError
def initialize
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
end
end
# Initializes an AgentBuilder with necessary attributes.
# @param email [String] the email of the user.
# @param name [String] the name of the user.
# @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
account.with_lock do
raise LimitExceededError unless can_add_agent?
ActiveRecord::Base.transaction do
@user = find_or_create_user
create_account_user
end
end
@user
end
private
def can_add_agent?
account.usage_limits[:agents] > account.account_users.count
end
# Finds a user by email or creates a new one with a temporary password.
# @return [User] the found or created user.
def find_or_create_user
user = User.from_email(email)
return user if user
@name = email.split('@').first if @name.blank?
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
AgentBuilder.prepend_mod_with('AgentBuilder')
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock!
# 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)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
+2 -41
View File
@@ -59,49 +59,10 @@ class ContactInboxBuilder
end
def create_contact_inbox
attrs = {
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: @source_id
}
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
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
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
private
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact
account.contacts.create!(
name: contact_name,
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
@@ -59,42 +59,13 @@ class ContactInboxWithContactBuilder
)
end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact
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)
return if identifier.blank?
@@ -104,7 +75,7 @@ class ContactInboxWithContactBuilder
def find_contact_by_email(email)
return if email.blank?
account.contacts.from_email(email)
account.contacts.find_by(email: email.downcase)
end
def find_contact_by_phone_number(phone_number)
-52
View File
@@ -1,52 +0,0 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
def channel
@channel ||= inbox.channel
end
def account
@account ||= inbox.account
end
def conversation
@conversation ||= message.conversation
end
def custom_sender_name
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
end
def sender_name(sender_email)
# Friendly: <agent_name> from <business_name>
# Professional: <business_name>
if inbox.friendly?
I18n.t(
'conversations.reply.email.header.friendly_name',
sender_name: custom_sender_name,
business_name: business_name,
from_email: sender_email
)
else
I18n.t(
'conversations.reply.email.header.professional_name',
business_name: business_name,
from_email: sender_email
)
end
end
def business_name
inbox.sanitized_business_name
end
def account_support_email
# Parse the email to ensure it's in the correct format, the user
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
end
-51
View File
@@ -1,51 +0,0 @@
class Email::FromBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
return sender_name(account_support_email) unless inbox.email?
from_email = case email_channel_type
when :standard_imap_smtp,
:google_oauth,
:microsoft_oauth,
:forwarding_own_smtp
channel.email
when :imap_chatwoot_smtp,
:forwarding_chatwoot_smtp
channel.verified_for_sending ? channel.email : account_support_email
else
account_support_email
end
sender_name(from_email)
end
private
def email_channel_type
return :google_oauth if channel.google?
return :microsoft_oauth if channel.microsoft?
return :standard_imap_smtp if imap_and_smtp_enabled?
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
return :forwarding_own_smtp if forwarding_with_own_smtp?
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
:unknown
end
def imap_and_smtp_enabled?
channel.imap_enabled && channel.smtp_enabled
end
def imap_enabled_without_smtp?
channel.imap_enabled && !channel.smtp_enabled
end
def forwarding_with_own_smtp?
!channel.imap_enabled && channel.smtp_enabled
end
def forwarding_without_smtp?
!channel.imap_enabled && !channel.smtp_enabled
end
end
-21
View File
@@ -1,21 +0,0 @@
class Email::ReplyToBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
reply_to = if inbox.email?
channel.email
elsif inbound_email_enabled?
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
else
account_support_email
end
sender_name(reply_to)
end
private
def inbound_email_enabled?
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
end
end
@@ -25,9 +25,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
build_contact_inbox
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
@@ -53,23 +51,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
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
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@@ -91,21 +73,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
external_url: attachment['url'] || attachment.dig('payload', 'url')
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Both `share` and `post` attachment types carry a page URL rather than a media file,
# so map them to `fallback` (which keeps the title/link without attempting a download).
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if [:share, :post].include?(type.to_sym)
super
end
def conversation_params
{
account_id: @inbox.account_id,
@@ -115,19 +87,12 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@@ -140,15 +105,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
}
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
@@ -166,6 +127,4 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
process_contact_params_result(result)
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end
@@ -1,201 +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)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
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,
status: @outgoing_echo ? :delivered : :sent,
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][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present?
end
def find_message_by_source_id(source_id)
return unless source_id
@message = Message.find_by(source_id: source_id)
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)
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
private
def get_story_object_from_source_id(source_id)
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
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
def attachments
@messaging[:message][:attachments] || {}
end
def handle_error_response(response)
parsed_response = JSON.parse(response.body)
error_code = parsed_response.dig('error', 'code')
def message_type
@outgoing_echo ? :outgoing : :incoming
end
# https://developers.facebook.com/docs/messenger-platform/error-codes
# Access token has expired or become invalid.
channel.authorization_error! if error_code == 190
def message_identifier
message[:mid]
end
# There was a problem scraping data from the provided link.
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
if error_code == 1_609_005
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
def message_source_id
@outgoing_echo ? recipient_id : sender_id
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 ||= 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
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end
def base_uri
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
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: { 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
@@ -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
+17 -131
View File
@@ -1,8 +1,5 @@
class Messages::MessageBuilder
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
@@ -10,44 +7,25 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
end
def perform
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content
@message.save!
@message
end
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 safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
def process_attachments
return if @attachments.blank?
@@ -57,25 +35,16 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = attachment_file_type(uploaded_attachment)
tag_voice_message(attachment)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
end
end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@@ -91,20 +60,18 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails
end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string)
return [] if email_string.blank?
email_string.gsub(/\s+/, '').split(',')
end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -148,90 +115,9 @@ class Messages::MessageBuilder
private: @private,
sender: sender,
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
echo_id: @params[:echo_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(processed_content)
end
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def custom_email_content_provided?
@params[:email_html_content].present?
end
def build_custom_html_content
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
html_content[:full] = @params[:email_html_content]
html_content[:reply] = @params[:email_html_content]
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
@@ -2,30 +2,14 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one unsupported file
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -37,17 +21,13 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
file_type = normalize_file_type(attachment['type'])
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -59,17 +39,9 @@ class Messages::Messenger::MessageBuilder
end
def file_type_params(attachment)
# Handle different URL field names for different attachment types
url = case attachment['type'].to_sym
when :ig_story
attachment['payload']['story_media_url']
else
attachment['payload']['url']
end
{
external_url: url,
remote_file_url: url
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
@@ -96,58 +68,26 @@ class Messages::Messenger::MessageBuilder
message.save!
end
def fetch_ig_story_link(attachment)
message = attachment.message
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
message.content_attributes[:image_type] = 'ig_story'
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
message.save!
end
def fetch_ig_post_link(attachment)
message = attachment.message
message.content_attributes[:image_type] = 'ig_post'
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
message.save!
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
private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
[:template, :unsupported_type].include? attachment_type.to_sym
end
end
+6 -25
View File
@@ -1,20 +1,22 @@
class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
def perform
return unless user_subscribed_to_notification?
build_notification
end
private
def current_user
def secondary_actor
Current.user
end
def user_subscribed_to_notification?
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
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("push_#{notification_type}?")
@@ -23,32 +25,11 @@ class NotificationBuilder
end
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'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
account: account,
primary_actor: primary_actor,
# secondary_actor is secondary_actor if present, else current_user
secondary_actor: secondary_actor || current_user
secondary_actor: secondary_actor
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
+9 -42
View File
@@ -1,7 +1,6 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -16,15 +15,12 @@ class V2::ReportBuilder
end
def timeseries
return send(params[:metric]) if metric_valid?
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
send(params[:metric])
end
# For backward compatible with old report
def build
if %w[avg_first_response_time avg_resolution_time reply_time].include?(params[:metric])
if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
end
@@ -37,34 +33,18 @@ class V2::ReportBuilder
def summary
{
conversations_count: conversations.count,
incoming_messages_count: incoming_messages.count,
outgoing_messages_count: outgoing_messages.count,
conversations_count: conversations_count.values.sum,
incoming_messages_count: incoming_messages_count.values.sum,
outgoing_messages_count: outgoing_messages_count.values.sum,
avg_first_response_time: avg_first_response_time_summary,
avg_resolution_time: avg_resolution_time_summary,
resolutions_count: resolutions.count,
reply_time: reply_time_summary
}
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
resolutions_count: resolutions_count.values.sum
}
end
def conversation_metrics
if params[:type].equal?(:account)
live_conversations
conversations
else
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
end
@@ -72,18 +52,6 @@ class V2::ReportBuilder
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
@inbox ||= account.inboxes.find(params[:id])
end
@@ -121,19 +89,18 @@ class V2::ReportBuilder
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: live_conversations
metric: conversations
}
end
end
def live_conversations
def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
metric = {
open: @open_conversations.count,
unattended: @open_conversations.unattended.count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric[:pending] = @open_conversations.pending.count if params[:type].equal?(:account)
metric
end
end
@@ -1,35 +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 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

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