Compare commits

..
Author SHA1 Message Date
Shivam Mishra 648b9c9507 chore: add logs for debug 2025-05-22 10:08:19 +05:30
611fc82847 feat: Add components to show steps in the copilot thinking process (#11530)
This PR adds the components for new Copilot UI
- Added a Header component
- Added a thinking block.
- Update the outline on copilot input

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-22 10:08:19 +05:30
ffad8458cb feat: Delete a contact from the contacts page (#11529)
# Pull Request Template

## Description


**This PR includes:**

1. Adds the ability to delete a contact from the contacts list accordion
section.
2. Improves the expand/collapse transition for the accordion.


Fixes
[CW-4375](https://linear.app/chatwoot/issue/CW-4375/allow-users-to-delete-a-contact-from-the-contacts-page)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/8c897d24737f40f6b8b29fef76ba18e2?sid=70910b9d-f3db-4d54-8bfc-820db680e537


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-05-22 10:08:19 +05:30
5869a98e6e fix: account email validation during signup (#11307)
- Refactor email validation logic to be a service
- Use the service for both email/pass signup and Google SSO
- fix account email validation during signup
- Use `blocked_domain` setting for both email/pass signup and Google
Sign In [`BLOCKED_DOMAIN` via GlobalConfig]
- add specs for `account_builder`
- add specs for the new service

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-22 10:08:19 +05:30
dde08b0bd9 feat: Add support for search_conversations in copilot (#11520)
Earlier, we were manually checking if a user was an agent and filtering
their conversations based on inboxes. This logic should have been part
of the conversation permissions service.

This PR moves the check to the right place and updates the logic
accordingly.

Other updates:
- Add support for search_conversations service for copilot.
- Use PermissionFilterService in contacts/conversations, conversations,
copilot search_conversations.

---------

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra 5a4201ae58 chore: Display Agent Bot token after creation (#11488)
This PR includes:

- Displaying the Agent Bot token after creation
- Updating the avatar icon when an avatar image is not present

Fixes: https://linear.app/chatwoot/issue/CW-4337/agent-bot-token-not-visible
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra fc8fe9a8d1 feat: Prevent saving preferences and status when impersonating (#11164)
This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings.

Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements
2025-05-22 10:08:19 +05:30
Muhsin KelothandShivam Mishra 2fe0761fd5 fix: Display message content for CSAT messages in non-widget inboxes (#11528)
We made so many improvements for CSAT via https://github.com/chatwoot/chatwoot/pull/11485. However, we missed showing message content in the dashboard for CSAT URLs created in non-widget inboxes. This PR fixes the issue by ensuring that CSAT-configured messages are passed along with CSAT responses, otherwise defaulting to the translation.
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra 9dd2d14655 fix: Status not updating when creating a Linear issue (#11523) 2025-05-22 10:08:19 +05:30
6043 changed files with 97460 additions and 561490 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: ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - 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
+62 -245
View File
@@ -1,9 +1,7 @@
version: 2.1 version: 2.1
orbs: orbs:
node: circleci/node@6.1.0 node: circleci/node@6.1.0
qlty-orb: qltysh/qlty-orb@0.0
# Shared defaults for setup steps
defaults: &defaults defaults: &defaults
working_directory: ~/build working_directory: ~/build
machine: machine:
@@ -13,147 +11,21 @@ defaults: &defaults
RAILS_LOG_TO_STDOUT: false RAILS_LOG_TO_STDOUT: false
COVERAGE: true COVERAGE: true
LOG_LEVEL: warn LOG_LEVEL: warn
parallelism: 4
jobs: jobs:
# Separate job for linting (no parallelism needed) build:
lint:
<<: *defaults
steps:
- checkout
# Install minimal system dependencies for linting
- run:
name: Install System Dependencies
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
- 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
- node/install:
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
# Swagger verification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
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
# 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
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
# Separate job for frontend tests
frontend-tests:
<<: *defaults <<: *defaults
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '24.13' node-version: '23.7'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
override-ci-command: pnpm i override-ci-command: pnpm i
- run: node --version
- run: - run: pnpm --version
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
- node/install-packages:
pkg-manager: pnpm
override-ci-command: pnpm i
- run: - run:
name: Add PostgreSQL repository and update name: Add PostgreSQL repository and update
command: | command: |
@@ -201,15 +73,15 @@ jobs:
libvips libvips
- run: - run:
name: Install RVM and Ruby 3.4.4 name: Install RVM and Ruby 3.3.3
command: | command: |
sudo apt-get install -y gpg sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable \curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
rvm install "3.4.4" rvm install "3.3.3"
rvm use 3.4.4 --default rvm use 3.3.3 --default
gem install bundler -v 2.5.16 gem install bundler -v 2.5.16
- run: - run:
@@ -217,51 +89,29 @@ jobs:
command: | command: |
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# pnpm 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: - run:
name: Configure and Start OpenSearch name: Download cc-test-reporter
command: | command: |
# Configure OpenSearch for single-node testing mkdir -p ~/tmp
cat > /opt/opensearch/config/opensearch.yml \<< EOF curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
cluster.name: chatwoot-test chmod +x ~/tmp/cc-test-reporter
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
# Swagger verification
- run: - run:
name: Wait for OpenSearch to be ready name: Verify swagger API specification
command: | command: |
echo "Waiting for OpenSearch to start..." bundle exec rake swagger:build
for i in {1..30}; do if [[ `git status swagger/swagger.json --porcelain` ]]
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then then
echo "OpenSearch is ready!" echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 0 exit 1
fi fi
echo "Waiting... ($i/30)" 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
sleep 2 java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database # we remove the FRONTED_URL from the .env before running the tests
- run: - run:
name: Database Setup and Configure Environment Variables name: Database Setup and Configure Environment Variables
command: | command: |
@@ -277,98 +127,65 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup # Database setup
- run: - run:
name: Run DB migrations name: Run DB migrations
command: bundle exec rails db:chatwoot_prepare command: bundle exec rails db:chatwoot_prepare
# Run backend tests (parallelized) # 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
# ESLint linting
- run:
name: eslint
command: pnpm run eslint
- run:
name: Run frontend tests
command: |
mkdir -p ~/build/coverage/frontend
~/tmp/cc-test-reporter before-build
pnpm run test:coverage
- run:
name: Code Climate Test Coverage (Frontend)
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-results/rspec
mkdir -p ~/tmp/test-artifacts mkdir -p ~/tmp/test-artifacts
mkdir -p ~/build/coverage/backend mkdir -p ~/build/coverage/backend
~/tmp/cc-test-reporter before-build
# Use round-robin distribution (same as GitHub Actions) for better test isolation TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
# This prevents tests with similar timing from being grouped on the same runner bundle exec rspec --format progress \
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 \
--format RspecJunitFormatter \ --format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \ --out ~/tmp/test-results/rspec.xml \
-- $TESTS -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# Store test results for better splitting in future runs - run:
- store_test_results: name: Code Climate Test Coverage (Backend)
path: ~/tmp/test-results command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
- run: - run:
name: Move coverage files if they exist name: List coverage directory contents
command: | command: |
if [ -d "coverage" ]; then ls -R ~/build/coverage
mkdir -p ~/build/coverage
cp -r coverage ~/build/coverage/backend || true
fi
when: always
- persist_to_workspace: - persist_to_workspace:
root: ~/build root: ~/build
paths: paths:
- coverage - coverage
# Collect coverage from all jobs
coverage:
<<: *defaults
steps:
- checkout
- attach_workspace:
at: ~/build
# Qlty coverage publish
- qlty-orb/coverage_publish:
files: |
coverage/frontend/lcov.info
- run:
name: List coverage directory contents
command: |
ls -R ~/build/coverage || echo "No coverage directory"
- store_artifacts:
path: coverage
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
build:
jobs:
- lint
- frontend-tests
- backend-tests
- coverage:
requires:
- frontend-tests
- backend-tests
- build:
requires:
- lint
- coverage
+62
View File
@@ -0,0 +1,62 @@
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/**/**'
- '**/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/captain/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'
+1 -11
View File
@@ -4,15 +4,5 @@ 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 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 COPY . /workspace
RUN yarn && gem install bundler && bundle install
+42 -65
View File
@@ -1,16 +1,12 @@
ARG VARIANT="ubuntu-22.04"
ARG VARIANT
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION ARG NODE_VERSION
ARG RUBY_VERSION ARG RUBY_VERSION
ARG USER_UID ARG USER_UID
ARG USER_GID ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -19,80 +15,61 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \ && chmod -R $USER_UID:$USER_GID /home/vscode; \
fi fi
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ && apt-get -y install --no-install-recommends \
&& apt-get update \ build-essential \
&& apt-get -y install --no-install-recommends \ libssl-dev \
build-essential \ zlib1g-dev \
libssl-dev \ gnupg2 \
zlib1g-dev \ tar \
gnupg \ tzdata \
tar \ postgresql-client \
tzdata \ libpq-dev \
postgresql-client \ yarn \
libpq-dev \ git \
git \ imagemagick \
imagemagick \ tmux \
libyaml-dev \ zsh \
curl \ git-flow \
ca-certificates \ npm \
tmux \ libyaml-dev
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install rbenv and ruby for root user first # Install rbenv and ruby
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \ RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \ && echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc && echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH" 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 PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \ RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \ rbenv global $RUBY_VERSION && \
rbenv versions rbenv versions
# Set up rbenv for vscode user # Install overmind
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \ 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 \ && gunzip overmind.gz \
&& mv overmind /usr/local/bin \ && sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \ && 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 \ # Install gh
&& apt-get install -y --no-install-recommends gh \ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& apt-get clean \ && 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 \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
RUN chown vscode:vscode /workspace COPY . /workspace
# set up node js, pnpm and claude code in single layer # set up ruby
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \ COPY Gemfile Gemfile.lock ./
&& npm cache clean --force RUN gem install bundler && bundle install
# Switch to vscode user # set up node js
USER vscode RUN npm install n -g && \
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH" n $NODE_VERSION
RUN npm install --global yarn
# Copy dependency files first for better caching RUN yarn
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
+8 -17
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml", "dockerComposeFile": "docker-compose.yml",
"settings": { "settings": {
"terminal.integrated.shell.linux": "/bin/zsh", "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
}
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": [
"Shopify.ruby-lsp", "rebornix.Ruby",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner", "davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot", "github.copilot",
"mrmlnc.vscode-duplicate" "mrmlnc.vscode-duplicate"
], ],
@@ -32,15 +23,15 @@
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 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": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3036": { "3035": {
"label": "Vite Dev Server" "label": "Webpack Dev Server"
}, },
"8025": { "8025": {
"label": "Mailhog UI" "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
+15 -2
View File
@@ -5,14 +5,27 @@
version: '3' version: '3'
services: services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app: app:
build: build:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
USER_GID: '1000' USER_GID: '1000'
+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 '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ 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 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
# Setup Claude Code API key if available # uncomment the webpacker env variable
if [ -n "$CLAUDE_CODE_API_KEY" ]; then sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
mkdir -p ~/.claude # fix the error with webpacker
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
# codespaces make the ports public # 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
-7
View File
@@ -1,21 +1,14 @@
.bundle .bundle
.codex
.env .env
.env.* .env.*
.git
.github
.agents
docker-compose.* docker-compose.*
docker/Dockerfile docker/Dockerfile
docker/dockerfiles docker/dockerfiles
log log
spec
storage storage
tests
public/system public/system
tmp tmp
.codeclimate.yml .codeclimate.yml
coverage
public/packs public/packs
node_modules node_modules
vendor/bundle vendor/bundle
+3 -27
View File
@@ -6,13 +6,6 @@
# Use `rake secret` to generate this variable # Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex 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 # Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000 FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages # To use a dedicated URL for help center pages
@@ -98,8 +91,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN= MAILER_INBOUND_EMAIL_DOMAIN=
# 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 the appropriate ingress channel with regards to incoming emails
# Possible values are : # Possible values are :
# relay for Exim, Postfix, Qmail # relay for Exim, Postfix, Qmail
@@ -107,7 +98,6 @@ MAILER_INBOUND_EMAIL_DOMAIN=
# mandrill for Mandrill # mandrill for Mandrill
# postmark for Postmark # postmark for Postmark
# sendgrid for Sendgrid # sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE= RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service # Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
@@ -117,10 +107,6 @@ RAILS_INBOUND_EMAIL_PASSWORD=
MAILGUN_INGRESS_SIGNING_KEY= MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_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: # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format: # Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
@@ -222,7 +208,6 @@ 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 ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL= # DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database # MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY= # IP_LOOKUP_API_KEY=
@@ -234,10 +219,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # 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 # 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 ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false # CW_API_ONLY_SERVER=false
@@ -268,18 +249,13 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10 # SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features (Captain) # AI powered features
# The OpenAI API key and endpoint for Captain are not configured via .env. ## OpenAI key
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT). # OPENAI_API_KEY=
# Housekeeping/Performance related configurations # Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes # Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
-1
View File
@@ -103,7 +103,6 @@ module.exports = {
'⌘', '⌘',
'📄', '📄',
'🎉', '🎉',
'🚀',
'💬', '💬',
'👥', '👥',
'📥', '📥',
-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}`);
-8
View File
@@ -6,14 +6,6 @@ name: Deploy Check
on: on:
pull_request: 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: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
+1 -4
View File
@@ -8,9 +8,6 @@ on:
branches: branches:
- develop - develop
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -29,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
-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
@@ -5,14 +5,6 @@ on:
branches: branches:
- develop - 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: jobs:
log_lines_check: log_lines_check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
publish-code-space-image: publish-code-space-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,5 +19,6 @@ jobs:
- name: Build the Codespace Base Image - name: Build the Codespace Base Image
run: | run: |
docker compose -f .devcontainer/docker-compose.base.yml build base docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker push ghcr.io/chatwoot/chatwoot_codespace:latest docker push ghcr.io/chatwoot/chatwoot_codespace:latest
+2 -11
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -78,12 +75,6 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} 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 outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ee
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ee
- name: Export digest - name: Export digest
run: | run: |
@@ -131,7 +122,7 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}" TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi fi
docker buildx imagetools create -t $TAG \ docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -145,5 +136,5 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}" TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi fi
docker buildx imagetools inspect $TAG docker buildx imagetools inspect $TAG
+2 -11
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -83,12 +80,6 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} 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 outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ce
- name: Export digest - name: Export digest
run: | run: |
@@ -136,7 +127,7 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi fi
docker buildx imagetools create -t $TAG \ docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *) $(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -150,5 +141,5 @@ jobs:
else else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce" TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi fi
docker buildx imagetools inspect $TAG docker buildx imagetools inspect $TAG
+16 -80
View File
@@ -1,6 +1,4 @@
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
permissions:
contents: read
on: on:
push: push:
branches: branches:
@@ -10,58 +8,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# Separate linting jobs for faster feedback test:
lint-backend: runs-on: ubuntu-22.04
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]
services: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg15
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ''
@@ -69,6 +20,8 @@ jobs:
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ports: ports:
- 5432:5432 - 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >- options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data --mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready --health-cmd pg_isready
@@ -76,7 +29,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
redis: redis:
image: redis:alpine image: redis
ports: ports:
- 6379:6379 - 6379:6379
options: --entrypoint redis-server options: --entrypoint redis-server
@@ -90,11 +43,11 @@ jobs:
- uses: ruby/setup-ruby@v1 - uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
@@ -111,36 +64,19 @@ jobs:
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run backend tests (parallelized) - name: Run frontend tests
run: pnpm run test:coverage
# Run rails tests
- name: Run backend tests
run: | run: |
# Get all spec files and split them using round-robin distribution bundle exec rspec --profile=10 --format documentation
# This ensures slow tests are distributed evenly across all nodes
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
TESTS=""
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
if [ -n "$TESTS" ]; then
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
fi
env: env:
NODE_OPTIONS: --openssl-legacy-provider NODE_OPTIONS: --openssl-legacy-provider
- name: Upload test results - name: Upload rails log folder
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: rspec-results-${{ matrix.ci_node_index }} name: rails-log-folder
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 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/
+1 -9
View File
@@ -5,14 +5,6 @@ on:
branches: branches:
- develop - 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: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -31,7 +23,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm
+2 -9
View File
@@ -7,9 +7,6 @@ on:
- master - master
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
strategy: strategy:
@@ -39,9 +36,5 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
load: false load: false
build-args: | cache-from: type=gha
GIT_SHA=${{ github.sha }} cache-to: type=gha,mode=max
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
type=gha,scope=${{ matrix.platform }}-ee
-10
View File
@@ -94,13 +94,3 @@ yarn-debug.log*
.vscode .vscode
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
Procfile.worktree
+1 -1
View File
@@ -1 +1 @@
24.13.0 20.5.1
-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
+9 -191
View File
@@ -1,14 +1,9 @@
plugins: require:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb - ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb - ./rubocop/custom_cop_location.rb
- ./rubocop/attachment_download.rb
- ./rubocop/one_class_per_file.rb
Layout/LineLength: Layout/LineLength:
Max: 150 Max: 150
@@ -18,67 +13,44 @@ Metrics/ClassLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
Metrics/MethodLength: Metrics/MethodLength:
Max: 19 Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/ExponentialNotation: Style/ExponentialNotation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false
Style/SymbolArray: Style/SymbolArray:
Enabled: false Enabled: false
Style/OpenStructUse: Style/OpenStructUse:
Enabled: false Enabled: false
Chatwoot/AttachmentDownload:
Enabled: true
Exclude:
- 'spec/**/*'
- 'test/**/*'
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb' - 'app/dispatchers/dispatcher.rb'
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/01_redis.rb' - 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb' - 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
- 'lib/global_config.rb' - 'lib/global_config.rb'
Style/ClassVars: Style/ClassVars:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper: Lint/MissingSuper:
Exclude: Exclude:
- 'app/drops/base_drop.rb' - 'app/drops/base_drop.rb'
Lint/SymbolConversion: Lint/SymbolConversion:
Enabled: false Enabled: false
Lint/EmptyBlock: Lint/EmptyBlock:
Exclude: Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant: Lint/OrAssignmentToConstant:
Exclude: Exclude:
- 'lib/redis/config.rb' - 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Max: 30 Max: 30
Exclude: Exclude:
@@ -86,16 +58,10 @@ Metrics/BlockLength:
- '**/routes.rb' - '**/routes.rb'
- 'config/environments/*' - 'config/environments/*'
- db/schema.rb - db/schema.rb
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/seeders/message_seeder.rb - lib/seeders/message_seeder.rb
- spec/support/slack_stubs.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: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -105,101 +71,74 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb' - 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb' - 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb' - 'app/controllers/survey/responses_controller.rb'
Rails/FindEach: Rails/FindEach:
Enabled: true Enabled: true
Include: Include:
- 'app/**/*.rb' - 'app/**/*.rb'
Rails/CompactBlank: Rails/CompactBlank:
Enabled: false Enabled: false
Rails/EnvironmentVariableAccess: Rails/EnvironmentVariableAccess:
Enabled: false Enabled: false
Rails/TimeZoneAssignment: Rails/TimeZoneAssignment:
Enabled: false Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo: Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false 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: Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Exclude: Exclude:
- 'config/application.rb' - 'config/application.rb'
- 'config/initializers/monkey_patches/*' - 'config/initializers/monkey_patches/*'
Style/MapToHash: Style/MapToHash:
Enabled: false Enabled: false
Style/HashSyntax: Style/HashSyntax:
Enabled: true Enabled: true
EnforcedStyle: no_mixed_keys EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never EnforcedShorthandSyntax: never
RSpec/NestedGroups: RSpec/NestedGroups:
Enabled: true Enabled: true
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
RSpec/StubbedMock: RSpec/StubbedMock:
Enabled: false Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber: Naming/VariableNumber:
Enabled: false Enabled: false
Naming/MemoizedInstanceVariableName: Naming/MemoizedInstanceVariableName:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/builders/account_builder.rb' - 'app/builders/account_builder.rb'
- 'app/models/attachment.rb' - 'app/models/attachment.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
Metrics/AbcSize: Metrics/AbcSize:
Max: 26 Max: 26
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.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/integrations/hook.rb'
- 'app/models/canned_response.rb' - 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb' - 'app/models/telegram_bot.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Exclude: Exclude:
- 'db/migrate/20230503101201_create_sla_policies.rb' - 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet: RSpec/IndexedLet:
Enabled: false Enabled: false
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 7 Max: 7
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14
@@ -213,9 +152,6 @@ UseFromEmail:
CustomCopLocation: CustomCopLocation:
Enabled: true Enabled: true
Style/OneClassPerFile:
Enabled: true
AllCops: AllCops:
NewCops: enable NewCops: enable
Exclude: Exclude:
@@ -230,121 +166,3 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - '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
+1 -1
View File
@@ -1 +1 @@
3.4.4 3.3.3
+3 -63
View File
@@ -4,20 +4,12 @@
- **Setup**: `bundle install && pnpm install` - **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` - **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 JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a` - **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch` - **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev` - **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 ## Code Style
@@ -43,45 +35,15 @@
## General Guidelines ## General Guidelines
- Prefer the smallest production-ready change that solves the current problem. - MVP focus: Least code change, happy-path only
- 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. - No unnecessary defensive programming
- 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 - Break down complex tasks into small, testable units
- Iterate after confirmation - Iterate after confirmation
- Avoid writing specs unless explicitly asked - 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 - Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it - 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 - 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 ## Project-Specific
- **Translations**: - **Translations**:
@@ -93,26 +55,4 @@
## Ruby Best Practices ## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles - 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.
+15 -50
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.4' ruby '3.3.3'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors' gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1' gem 'rails', '~> 7.0.8.4'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@@ -21,8 +21,6 @@ gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -35,14 +33,10 @@ gem 'liquid'
gem 'commonmarker' gem 'commonmarker'
# Validate Data against JSON Schema # Validate Data against JSON Schema
gem 'json_schemer' gem 'json_schemer'
# used in swagger build
gem 'json_refs'
# Rack middleware for blocking & throttling abusive requests # Rack middleware for blocking & throttling abusive requests
gem 'rack-attack', '>= 6.7.0' gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files # a utility tool for streaming, flexible and safe downloading of remote files
gem 'down' gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0 # authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth' gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2 # Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
@@ -58,9 +52,6 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby',
gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'google-cloud-storage', '>= 1.48.0', require: false
gem 'image_processing' gem 'image_processing'
##-- for actionmailbox --##
gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'
@@ -69,14 +60,10 @@ gem 'redis-namespace'
# super fast record imports in bulk # super fast record imports in bulk
gem 'activerecord-import' gem 'activerecord-import'
gem 'searchkick'
gem 'opensearch-ruby'
gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1' gem 'puma'
gem 'vite_rails' gem 'vite_rails'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
@@ -85,13 +72,9 @@ gem 'barnes'
gem 'devise', '>= 4.9.4' gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth', '>= 1.2.3' gem 'devise_token_auth', '>= 1.2.3'
gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization # authorization
gem 'jwt', '~> 2.10', '>= 2.10.3' gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.20.1' gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3' gem 'administrate-field-active_storage', '>= 1.0.3'
@@ -104,14 +87,14 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---## ##--- gems for channels ---##
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'line-bot-api' gem 'line-bot-api'
gem 'twilio-ruby' gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty' # gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5' gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.7.0' gem 'slack-ruby-client', '~> 2.5.2'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc' gem 'grpc'
@@ -123,7 +106,7 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---# ##-- apm and error monitoring ---#
# loaded only when environment variables are set. # loaded only when environment variables are set.
# ref application.rb # ref application.rb
gem 'datadog', '~> 2.0', require: false gem 'ddtrace', require: false
gem 'elastic-apm', require: false gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
@@ -133,11 +116,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1' gem 'sidekiq', '>= 7.3.1'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0' gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@@ -166,7 +147,7 @@ gem 'working_hours'
gem 'pg_search' gem 'pg_search'
# Subscriptions, Billing # Subscriptions, Billing
gem 'stripe', '~> 18.0' gem 'stripe'
## - helper gems --## ## - helper gems --##
## to populate db with sample data ## to populate db with sample data
@@ -182,7 +163,6 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth # need for google auth
gem 'omniauth', '>= 2.1.2' gem 'omniauth', '>= 2.1.2'
gem 'omniauth-saml'
gem 'omniauth-google-oauth2', '>= 1.1.3' gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2' gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
@@ -195,22 +175,9 @@ gem 'reverse_markdown'
gem 'iso-639' gem 'iso-639'
gem 'ruby-openai' 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 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
@@ -223,12 +190,15 @@ group :production do
end end
group :development do group :development do
gem 'annotaterb' gem 'annotate'
gem 'bullet' gem 'bullet'
gem 'letter_opener' gem 'letter_opener'
gem 'scss_lint', require: false gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1' gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'
# When we want to squash migrations # When we want to squash migrations
gem 'squasher' gem 'squasher'
@@ -237,8 +207,6 @@ group :development do
gem 'stackprof' gem 'stackprof'
# Should install the associated chrome extension to view query logs # Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3' gem 'meta_request', '>= 0.8.3'
gem 'tidewave'
end end
group :test do group :test do
@@ -248,7 +216,6 @@ group :test do
gem 'webmock' gem 'webmock'
# test profiling # test profiling
gem 'test-prof' gem 'test-prof'
gem 'simplecov_json_formatter', require: false
end end
group :development, :test do group :development, :test do
@@ -270,11 +237,9 @@ group :development, :test do
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false gem 'simplecov', '0.17.1', require: false
gem 'skooma'
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
+238 -446
View File
File diff suppressed because it is too large Load Diff
+3 -15
View File
@@ -1,7 +1,6 @@
# Variables # Variables
APP_NAME := chatwoot APP_NAME := chatwoot
RAILS_ENV ?= development RAILS_ENV ?= development
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
# Targets # Targets
setup: setup:
@@ -41,19 +40,8 @@ run:
fi fi
force_run: 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 ./.overmind.sock
rm -f tmp/pids/*.pid overmind start -f Procfile.dev
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -62,6 +50,6 @@ debug_worker:
overmind connect worker overmind connect worker
docker: docker:
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-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
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <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/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> <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>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2025, 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. # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application' 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 Rails.application.load_tasks
+1 -1
View File
@@ -1 +1 @@
4.15.1 3.13.0
+1 -1
View File
@@ -1 +1 @@
3.5.0 3.2.0
-4
View File
@@ -36,10 +36,6 @@
"REDIS_OPENSSL_VERIFY_MODE":{ "REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none" "value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
} }
}, },
"formation": { "formation": {
+2 -10
View File
@@ -6,7 +6,6 @@
# We don't want to update the name of the identified original contact. # We don't want to update the name of the identified original contact.
class ContactIdentifyAction class ContactIdentifyAction
include UrlHelper
pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }] pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform def perform
@@ -104,15 +103,8 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error # blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs @contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save! if @contact.changed? @contact.save!
enqueue_avatar_job Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present? && !@contact.avatar.attached?
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])
end end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return; if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') { if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10; textElement.textContent = '•'.repeat(10);
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true'; textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText); 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);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field { .cell-data__secret-field {
align-items: center; align-items: center;
color: $hint-grey;
display: flex; display: flex;
span { span {
flex: 0 0 auto; flex: 1;
} }
[data-secret-toggler], button {
[data-secret-copier] { margin-left: 5px;
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg { svg {
fill: currentColor; fill: currentColor;
height: 1.25rem;
width: 1.25rem;
} }
} }
} }
+1 -5
View File
@@ -44,11 +44,7 @@ class AccountBuilder
end end
def create_account def create_account
@account = Account.create!( @account = Account.create!(name: account_name, locale: I18n.locale)
name: account_name,
locale: I18n.locale,
custom_attributes: { 'onboarding_step' => 'account_details' }
)
Current.account = @account Current.account = @account
end end
+1 -4
View File
@@ -29,9 +29,8 @@ class AgentBuilder
user = User.from_email(email) user = User.from_email(email)
return user if user return user if user
@name = email.split('@').first if @name.blank?
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
end end
# Checks if the user needs confirmation. # Checks if the user needs confirmation.
@@ -53,5 +52,3 @@ class AgentBuilder
}.compact)) }.compact))
end end
end end
AgentBuilder.prepend_mod_with('AgentBuilder')
+2 -6
View File
@@ -59,13 +59,11 @@ class ContactInboxBuilder
end end
def create_contact_inbox def create_contact_inbox
attrs = { ::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id, contact_id: @contact.id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_id source_id: @source_id
} )
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox update_old_contact_inbox
@@ -103,5 +101,3 @@ class ContactInboxBuilder
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end end
end end
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
@@ -50,7 +50,7 @@ class ContactInboxWithContactBuilder
def create_contact def create_contact
account.contacts.create!( account.contacts.create!(
name: contact_name, name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number], phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email], email: contact_attributes[:email],
identifier: contact_attributes[:identifier], identifier: contact_attributes[:identifier],
@@ -59,11 +59,6 @@ class ContactInboxWithContactBuilder
) )
end end
def contact_name
name = contact_attributes[:name] || ::Haikunator.haikunate(1000)
name.truncate(ApplicationRecord::MAX_STRING_COLUMN_LENGTH, omission: '')
end
def find_contact def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
-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
@@ -91,21 +91,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment) def fallback_params(attachment)
{ {
fallback_title: attachment['title'] || attachment.dig('payload', 'title'), fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url') external_url: attachment['url']
} }
end 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 def conversation_params
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
@@ -115,19 +105,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
def message_params 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, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: @message_type, message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
content_attributes: content_attributes, content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : @contact_inbox.contact
} }
end end
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank? return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes) @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 end
def build_conversation def build_conversation
@@ -158,7 +139,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
message_type: message_type, message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier, source_id: message_identifier,
content: message_content, content: message_content,
sender: @outgoing_echo ? nil : contact, sender: @outgoing_echo ? nil : contact,
@@ -167,19 +147,16 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
} }
} }
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params params
end end
def message_already_exists? def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present? cw_message = conversation.messages.where(
end source_id: @messaging[:message][:mid]
).first
def find_message_by_source_id(source_id) cw_message.present?
return unless source_id
@message = Message.find_by(source_id: source_id)
end end
def all_unsupported_files? def all_unsupported_files?
+32 -113
View File
@@ -1,8 +1,5 @@
class Messages::MessageBuilder class Messages::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message attr_reader :message
def initialize(user, conversation, params) def initialize(user, conversation, params)
@@ -10,10 +7,8 @@ class Messages::MessageBuilder
@private = params[:private] || false @private = params[:private] || false
@conversation = conversation @conversation = conversation
@user = user @user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @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 = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@@ -25,9 +20,6 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments process_attachments
process_emails 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.save!
@message @message
end end
@@ -42,12 +34,30 @@ class Messages::MessageBuilder
params = convert_to_hash(@params) params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {}) content_attributes = params.fetch(:content_attributes, {})
return safe_parse_json(content_attributes) if content_attributes.is_a?(String) return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash) return content_attributes if content_attributes.is_a?(Hash)
{} {}
end end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments def process_attachments
return if @attachments.blank? return if @attachments.blank?
@@ -57,25 +67,16 @@ class Messages::MessageBuilder
file: uploaded_attachment file: uploaded_attachment
) )
attachment.file_type = attachment_file_type(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String)
tag_voice_message(attachment) file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end 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 def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@@ -91,20 +92,18 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails @message.content_attributes[:to_emails] = to_emails
end 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) def process_email_string(email_string)
return [] if email_string.blank? return [] if email_string.blank?
email_string.gsub(/\s+/, '').split(',') email_string.gsub(/\s+/, '').split(',')
end 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 def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -148,90 +147,10 @@ class Messages::MessageBuilder
private: @private, private: @private,
sender: sender, sender: sender,
content_type: @params[:content_type], content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id], echo_id: @params[:echo_id],
source_id: @params[:source_id] source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
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 end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
@@ -2,30 +2,14 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper include ::FileTypeHelper
def process_attachment(attachment) 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']) return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment) attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
# 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.save! attachment_obj.save!
if facebook_reel?(attachment) attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
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)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' 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' update_attachment_file_type(attachment_obj)
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
end end
def attach_file(attachment, file_url) def attach_file(attachment, file_url)
@@ -37,17 +21,13 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename, filename: attachment_file.original_filename,
content_type: attachment_file.content_type 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 end
def attachment_params(attachment) 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 } 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, :ig_reel].include? file_type
params.merge!(file_type_params(attachment)) params.merge!(file_type_params(attachment))
elsif file_type == :location elsif file_type == :location
params.merge!(location_params(attachment)) params.merge!(location_params(attachment))
@@ -59,17 +39,9 @@ class Messages::Messenger::MessageBuilder
end end
def file_type_params(attachment) 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, external_url: attachment['payload']['url'],
remote_file_url: url remote_file_url: attachment['payload']['url']
} }
end end
@@ -96,21 +68,6 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end 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 # 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)
{} {}
@@ -118,36 +75,7 @@ class Messages::Messenger::MessageBuilder
private 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) 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
end end
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification? return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions # skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention' 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!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user secondary_actor: secondary_actor || current_user
) )
end 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 end
-1
View File
@@ -1,7 +1,6 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper include DateRangeHelper
include ReportHelper include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
@@ -11,6 +11,10 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end
def prepare_report def prepare_report
account.account_users.map do |account_user| account.account_users.map do |account_user|
build_agent_stats(account_user) build_agent_stats(account_user)
+24 -25
View File
@@ -9,13 +9,27 @@ class V2::Reports::BaseSummaryBuilder
private private
def load_data def load_data
results = data_source.summary @conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
@conversations_count = results.transform_values { |data| data[:conversations_count] } def reporting_events
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } @reporting_events ||= account.reporting_events.where(created_at: range)
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } end
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end end
def group_by_key def group_by_key
@@ -26,26 +40,11 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end end
def data_source def get_grouped_average(events)
@data_source ||= Reports::DataSource.for( events.group(group_by_key).average(average_value_key)
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end end
def summary_dimension_type def average_value_key
{ ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end end
end end
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end end
def bot_resolutions_count def bot_resolutions_count
# Exclude conversations that also had a handoff in the same range — handoff wins account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
account.reporting_events.joins(:conversation).select(:conversation_id) created_at: range).distinct.count
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
end end
def bot_handoffs_count def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id) account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range) created_at: range).distinct.count
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end end
def bot_resolution_rate def bot_resolution_rate
@@ -1,38 +0,0 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -3,10 +3,23 @@ class V2::Reports::Conversations::BaseReportBuilder
private private
def builder_class(metric) AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
return unless Reports::ReportMetricRegistry.supported?(metric) COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
V2::Reports::Timeseries::ReportBuilder def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end end
def log_invalid_metric def log_invalid_metric
@@ -1,213 +0,0 @@
class V2::Reports::DrilldownBuilder
include DateRangeHelper
include TimezoneHelper
DEFAULT_GROUP_BY = 'day'.freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
MESSAGE_METRICS = {
'incoming_messages_count' => :incoming,
'outgoing_messages_count' => :outgoing
}.freeze
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
pattr_initialize :account, :params
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def meta
{
metric: metric,
record_type: record_type,
bucket: {
since: bucket_range.begin.to_i,
until: bucket_range.end.to_i
},
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count
}
end
def conversation_count
return paginated_records.total_count if conversation_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
if message_metric?
message_scope
elsif conversation_metric?
conversation_scope
else
reporting_event_scope
end
end
def message_scope
scope.messages
.where(account_id: account.id, created_at: bucket_range)
.public_send(MESSAGE_METRICS.fetch(metric))
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
def conversation_scope
scope.conversations
.where(account_id: account.id, created_at: bucket_range)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def reporting_event_scope
events = scope.reporting_events
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
.order(created_at: :desc)
if raw_count_strategy == :exclude_bot_handoffs
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
elsif raw_count_strategy == :distinct_conversation
events = events.where(id: distinct_conversation_event_ids(events))
end
events
end
def bot_handoff_conversation_ids_subquery
scope.reporting_events
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
end
def distinct_conversation_event_ids(events)
events.reorder(nil)
.where.not(conversation_id: nil)
.select('MAX(reporting_events.id)')
.group(:conversation_id)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
account,
metric,
use_business_hours?,
records
)
end
def bucket_range
@bucket_range ||= begin
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
bucket_end = bucket_end_for(bucket_start)
requested_start = Time.zone.at(params[:since].to_i)
requested_end = Time.zone.at(params[:until].to_i)
[bucket_start, requested_start].max...[bucket_end, requested_end].min
end
end
def bucket_end_for(bucket_start)
{
'hour' => bucket_start + 1.hour,
'day' => bucket_start + 1.day,
'week' => bucket_start + 1.week,
'month' => bucket_start + 1.month,
'year' => bucket_start + 1.year
}.fetch(group_by)
end
def scope
case dimension_type
when 'account' then account
when 'inbox' then inbox
when 'agent' then user
when 'label' then label
when 'team' then team
else
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
end
end
def inbox = @inbox ||= account.inboxes.find(params[:id])
def user = @user ||= account.users.find(params[:id])
def label = @label ||= account.labels.find(params[:id])
def team = @team ||= account.teams.find(params[:id])
def metric
params[:metric].to_s
end
def report_metric
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
end
def raw_event_name
report_metric&.raw_event_name
end
def raw_count_strategy
report_metric&.raw_count_strategy
end
def record_type
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
'conversation'
end
def message_metric?
MESSAGE_METRICS.key?(metric)
end
def conversation_metric?
metric == 'conversations_count'
end
def dimension_type
(params[:type].presence || 'account').to_s
end
def group_by
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
def current_page
[params[:page].to_i, DEFAULT_PAGE].max
end
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
end
@@ -1,199 +0,0 @@
class V2::Reports::DrilldownRecordSerializer
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
attr_reader :account, :metric, :use_business_hours, :records
def initialize(account, metric, use_business_hours, records = [])
@account = account
@metric = metric
@use_business_hours = use_business_hours
@records = records
end
def serialize(record)
return serialize_message(record) if record.is_a?(Message)
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
serialize_conversation(record)
end
private
def serialize_message(message, metric_value: nil, occurred_at: nil)
{
record_type: 'message',
conversation: conversation_attributes(message.conversation),
message: message_attributes(message),
metric_value: metric_value,
occurred_at: (occurred_at || message.created_at).to_i
}
end
def serialize_conversation_event(event)
inferred_message = inferred_message_for(event)
if inferred_message.present?
return serialize_message(
inferred_message,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event)
)
end
serialize_conversation(
event.conversation,
metric_value: event_metric_value(event),
occurred_at: event_timestamp(event),
event_name: event.name
)
end
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
serialized_record = {
record_type: 'conversation',
conversation: conversation_attributes(conversation),
message: nil,
metric_value: metric_value,
occurred_at: (occurred_at || conversation&.created_at)&.to_i
}
serialized_record[:event_name] = event_name if event_name.present?
serialized_record
end
def conversation_attributes(conversation)
return {} if conversation.blank?
{
id: conversation.id,
display_id: conversation.display_id,
contact_id: conversation.contact_id,
contact_name: conversation.contact&.name,
inbox_id: conversation.inbox_id,
inbox_name: conversation.inbox&.name,
assignee_id: conversation.assignee_id,
assignee_name: conversation.assignee&.name,
status: conversation.status,
created_at: conversation.created_at.to_i,
last_activity_at: conversation.last_activity_at.to_i,
last_message: last_message_attributes(conversation)
}
end
def message_attributes(message)
{
id: message.id,
content: message.content,
message_type: message.message_type,
sender_name: message.sender&.try(:name),
created_at: message.created_at.to_i
}
end
def last_message_attributes(conversation)
message = latest_messages_by_conversation_id[conversation.id]
return if message.blank?
message_attributes(message)
end
def inferred_message_for(event)
return unless MESSAGE_EVENT_METRICS.include?(metric)
return if event.conversation.blank? || event.event_end_time.blank?
inferred_messages_by_event_id[event.id]
end
def first_response_event_with_user?(event)
metric == 'avg_first_response_time' && event.user_id.present?
end
def message_inference_range(event)
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
end
def event_metric_value(event)
use_business_hours ? event.value_in_business_hours : event.value
end
def event_timestamp(event)
event.event_end_time || event.created_at
end
def latest_messages_by_conversation_id
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
{}
else
latest_messages.index_by(&:conversation_id)
end
end
def latest_messages
Message
.where(account_id: account.id, conversation_id: conversation_ids)
.where.not(message_type: :activity)
.select('DISTINCT ON (messages.conversation_id) messages.*')
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
.includes(:sender)
end
def inferred_messages_by_event_id
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
message_matches_event?(message, event)
end
end
end
def inferred_message_candidates
@inferred_message_candidates ||= if inference_events.blank?
[]
else
inferred_messages.to_a
end
end
def inferred_messages
Message
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
.where(created_at: inference_time_range)
.where(message_type: %i[outgoing template])
.includes(:sender)
.reorder(created_at: :desc, id: :desc)
end
def message_matches_event?(message, event)
message.conversation_id == event.conversation_id &&
message.created_at.between?(
message_inference_range(event).begin,
message_inference_range(event).end
) &&
message_sender_matches_event?(message, event)
end
def message_sender_matches_event?(message, event)
return true unless first_response_event_with_user?(event)
message.sender_id == event.user_id && message.sender_type == 'User'
end
def inference_time_range
event_end_times = inference_events.map(&:event_end_time)
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
end
def inference_events
@inference_events ||= records.select do |record|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
end
end
def conversation_ids
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
end
def conversation_id_for(record)
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
record.id
end
end
@@ -1,68 +0,0 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -1,65 +0,0 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -11,6 +11,18 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report def prepare_report
account.inboxes.map do |inbox| account.inboxes.map do |inbox|
build_inbox_stats(inbox) build_inbox_stats(inbox)
@@ -31,4 +43,8 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key def group_by_key
:inbox_id :inbox_id
end end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end end
@@ -1,112 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts,
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts
# Count resolution events, not conversations currently in resolved status
# Filter by reporting_event.created_at, not conversation.created_at
reporting_event_filter = { name: 'conversation_resolved', account_id: account.id }
reporting_event_filter[:created_at] = range if range.present?
ReportingEvent
.joins(conversation: { taggings: :tag })
.where(
reporting_event_filter.merge(
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
)
.group('tags.name')
.count
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins(conversation: { taggings: :tag })
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -1,79 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -6,6 +6,14 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count, attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time :avg_resolution_time, :avg_first_response_time, :avg_reply_time
def fetch_conversations_count
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report def prepare_report
account.teams.map do |team| account.teams.map do |team|
build_team_stats(team) build_team_stats(team)
@@ -0,0 +1,48 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,13 +1,12 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper include TimezoneHelper
include DateRangeHelper include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params pattr_initialize :account, :params
def scope def scope
case dimension_type.to_sym case params[:type].to_sym
when :account when :account
account account
when :inbox when :inbox
@@ -21,20 +20,6 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end end
end end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@@ -58,10 +43,4 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
def timezone def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset]) @timezone ||= timezone_name_from_offset(params[:timezone_offset])
end end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end end
@@ -0,0 +1,71 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -1,9 +0,0 @@
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
data_source.timeseries
end
def aggregate_value
data_source.aggregate
end
end
-74
View File
@@ -1,74 +0,0 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create] before_action :agent_bot, except: [:index, :create]
def index def index
@agent_bots = AgentBot.accessible_to(Current.account) @agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end end
def show; end def show; end
@@ -29,19 +29,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
def reset_secret
@agent_bot.reset_secret!
end
private private
def agent_bot def agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show' @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id]) @agent_bot ||= Current.account.agent_bots.find(params[:id])
end end
@@ -1,59 +0,0 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
def translate
head :not_implemented
end
def update_status
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(status: params[:status]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def update_category
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@articles.destroy_all
head :ok
end
private
def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def check_authorization
authorize(Article, :create?)
end
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -22,10 +22,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end def edit; end
def create def create
params_with_defaults = article_params @article = @portal.articles.create!(article_params)
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id]) @article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end end
@@ -40,8 +39,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end end
def reorder def reorder
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash]) Article.update_positions(params[:positions_hash])
render json: { positions: positions } head :ok
end end
private private
@@ -69,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params def article_params
params.require(:article).permit( params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status, :title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title, :locale, meta: [:title,
:description, :description,
{ tags: [] }] { tags: [] }]
@@ -2,8 +2,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes before_action :fetch_inboxes
def index def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox| agent_ids = @inboxes.map do |inbox|
authorize inbox, :show? authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id) member_ids = inbox.members.pluck(:user_id)
@@ -12,7 +10,6 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&) agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids) agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq @assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end end
private private
@@ -1,20 +0,0 @@
class Api::V1::Accounts::AssignmentPolicies::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy
before_action -> { check_authorization(AssignmentPolicy) }
def index
@inboxes = @assignment_policy.inboxes
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(
params[:assignment_policy_id]
)
end
def permitted_params
params.permit(:assignment_policy_id)
end
end
@@ -1,37 +0,0 @@
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies
end
def show; end
def create
@assignment_policy = Current.account.assignment_policies.create!(assignment_policy_params)
end
def update
@assignment_policy.update!(assignment_policy_params)
end
def destroy
@assignment_policy.destroy!
head :ok
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled,
:exclude_older_than_hours
)
end
end
@@ -1,6 +1,4 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end def show; end
def create def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@automation_rule = Current.account.automation_rules.new(automation_rules_permit) @automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = actions @automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions] @automation_rule.conditions = params[:conditions]
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid? render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save! @automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) } process_attachments
@automation_rule
end end
def update def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit) automation_rule_update
@automation_rule.actions = actions if params[:actions] process_attachments
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
rescue StandardError => e rescue StandardError => e
Rails.logger.error e Rails.logger.error e
render_could_not_create_error(@automation_rule.errors.messages) render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end end
end end
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule @automation_rule = new_rule
end end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :active, :name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }] actions: [:action_name, { action_params: [] }]
) )
@@ -1,12 +1,13 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
case normalized_type if type_matches?
when 'Conversation' ::BulkActionsJob.perform_later(
enqueue_conversation_job account: @current_account,
head :ok user: current_user,
when 'Contact' params: permitted_params
check_authorization_for_contact_action )
enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@@ -15,54 +16,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def normalized_type def type_matches?
params[:type].to_s.camelize ['Conversation'].include?(params[:type])
end end
def enqueue_conversation_job def permitted_params
::BulkActionsJob.perform_later( params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def delete_contact_action?
params[:action_name] == 'delete'
end
def check_authorization_for_contact_action
authorize(Contact, :destroy?) if delete_contact_action?
end
def conversation_params
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
# and then remove this method in favor of a common params method.
base = params.permit(
:snoozed_until,
fields: [:status, :assignee_id, :team_id]
)
append_common_bulk_attributes(base)
end
def contact_params
# TODO: remove this method in favor of a common params method.
# once legacy conversation payloads are migrated.
append_common_bulk_attributes({})
end
def append_common_bulk_attributes(base_params)
# NOTE: Conversation payloads historically diverged per action. Going forward we
# want all objects to share a common contract: `{ action_name, action_attributes }`
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
base_params.merge(common)
end end
end end
@@ -6,7 +6,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token = params[:page_access_token] page_access_token = params[:page_access_token]
page_id = params[:page_id] page_id = params[:page_id]
inbox_name = params[:inbox_name] inbox_name = params[:inbox_name]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
facebook_channel = Current.account.facebook_pages.create!( facebook_channel = Current.account.facebook_pages.create!(
page_id: page_id, user_access_token: user_access_token, page_id: page_id, user_access_token: user_access_token,
@@ -16,8 +15,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel) set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
end end
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Error in register_facebook_page: #{e.message}" Rails.logger.error "Error in register_facebook_page: #{e.message}"
@@ -33,14 +30,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def facebook_pages def facebook_pages
pages = [] @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
fb_pages = fb_object.get_connections('me', 'accounts')
pages.concat(fb_pages)
while fb_pages.respond_to?(:next_page) && (next_page = fb_pages.next_page)
fb_pages = next_page
pages.concat(fb_pages)
end
@page_details = mark_already_existing_facebook_pages(pages)
end end
def set_instagram_id(page_access_token, facebook_channel) def set_instagram_id(page_access_token, facebook_channel)
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id, params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {}) :scheduled_at, audience: [:type, :id], trigger_rules: {})
end end
end end
@@ -1,84 +0,0 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models).compact_blank.presence
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
end
def captain_feature_keys
Llm::Models.feature_keys.map(&:to_sym)
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
provider: route[:provider],
source: route[:source]
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -1,73 +0,0 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id],
user: Current.user
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')
@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal before_action :portal
before_action :check_authorization before_action :check_authorization
before_action :fetch_category, except: [:index, :create, :reorder] before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
def index def index
@@ -32,11 +32,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok head :ok
end end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private private
def fetch_category def fetch_category
@@ -44,7 +39,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end end
def portal def portal
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) @portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end end
def related_categories_records def related_categories_records
@@ -53,7 +48,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params def category_params
params.require(:category).permit( params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id :name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
) )
end end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def create def create
process_create process_create
rescue CustomExceptions::Inbox::LimitExceeded => e
render_error_response(e)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -66,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params def permitted_params
params.require(:twilio_channel).permit( params.require(:twilio_channel).permit(
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
) )
end end
end end
@@ -1,55 +0,0 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -1,18 +0,0 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
sort_on :phone_number, type: :string sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction] sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction] sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction] sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
@@ -14,21 +14,23 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts) @contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end end
def search def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = Current.account.contacts.where( contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%" search: "%#{params[:q].strip}%"
) )
@contacts = fetch_contacts_with_has_more(contacts) @contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
end end
def import def import
@@ -53,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts = fetch_contacts(contacts) @contacts_count = contacts.count
@contacts_count = @contacts.total_count @contacts = contacts.page(@current_page)
end end
def show; end def show; end
@@ -120,7 +122,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def resolved_contacts def resolved_contacts
return @resolved_contacts if @resolved_contacts return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts.resolved_contacts(use_crm_v2: Current.account.feature_enabled?('crm_v2')) @resolved_contacts = Current.account.contacts.resolved_contacts
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present? @resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts @resolved_contacts
@@ -131,32 +133,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contacts(contacts) def fetch_contacts(contacts)
# Build includes hash to avoid separate query when contact_inboxes are needed contacts_with_avatar = filtrate(contacts)
includes_hash = { avatar_attachment: [:blob] } .includes([{ avatar_attachment: [:blob] }])
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes .page(@current_page).per(RESULTS_PER_PAGE)
filtrate(contacts) return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
.includes(includes_hash)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts) contacts_with_avatar
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end end
def build_contact_inbox def build_contact_inbox
@@ -201,9 +184,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def fetch_contact def fetch_contact
contact_scope = Current.account.contacts @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -214,5 +195,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status render json: error, status: error_status
end end
end end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) || agent_bot_assignment? if params.key?(:assignee_id)
set_agent set_agent
elsif params.key?(:team_id) elsif params.key?(:team_id)
set_team set_team
@@ -13,23 +13,17 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private private
def set_agent def set_agent
resource = Conversations::AssignmentService.new( @agent = Current.account.users.find_by(id: params[:assignee_id])
conversation: @conversation, @conversation.assignee = @agent
assignee_id: params[:assignee_id], @conversation.save!
assignee_type: params[:assignee_type] render_agent
).perform
render_agent(resource)
end end
def render_agent(resource) def render_agent
case resource if @agent.nil?
when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else
render json: nil render json: nil
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
end end
end end
@@ -38,8 +32,4 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team) @conversation.update!(team: @team)
render json: @team render json: @team
end end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end end
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
end end
@@ -52,9 +52,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
render json: { content: translated_content } render json: { content: translated_content }
rescue Google::Cloud::Error => e
# `details` carries the clean human message; `message` includes gRPC debug noise
render_could_not_create_error(e.details.presence || e.message)
end end
private private

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