Compare commits

..
Author SHA1 Message Date
Pranav 0a8f3db76c Try abort controller 2024-11-27 11:05:06 -08:00
3697 changed files with 64850 additions and 228193 deletions
+6 -14
View File
@@ -19,19 +19,13 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '23.7' node-version: '20.12'
- 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: node --version
- run: pnpm --version - run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run: - run:
name: Install System Dependencies name: Install System Dependencies
@@ -40,9 +34,7 @@ jobs:
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \ libpq-dev \
redis-server \ redis-server \
postgresql-common \ postgresql \
postgresql-16 \
postgresql-16-pgvector \
build-essential \ build-essential \
git \ git \
curl \ curl \
@@ -73,16 +65,16 @@ 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
- run: - run:
name: Install Application Dependencies name: Install Application Dependencies
-1
View File
@@ -51,7 +51,6 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js' - 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js' - 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js' - 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js' - 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js' - 'app/javascript/survey/i18n/index.js'
+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: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
+19 -6
View File
@@ -5,17 +5,30 @@
version: '3' version: '3'
services: services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.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: '23.7.0' NODE_VERSION: "20.9.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"
volumes: volumes:
- ..:/workspace:cached - ..:/workspace:cached
@@ -27,7 +40,7 @@ services:
network_mode: service:db network_mode: service:db
db: db:
image: pgvector/pgvector:pg16 image: postgres:latest
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
+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
+1 -2
View File
@@ -216,8 +216,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# ENABLE_RACK_ATTACK=true # ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300 # RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true # ENABLE_RACK_ATTACK_WIDGET_API=true
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## 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
@@ -259,3 +257,4 @@ AZURE_APP_SECRET=
# 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
-14
View File
@@ -4,8 +4,6 @@ module.exports = {
'prettier', 'prettier',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended', 'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
], ],
overrides: [ overrides: [
{ {
@@ -231,18 +229,6 @@ module.exports = {
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'], 'import/extensions': ['off'],
'no-console': 'error', 'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
},
}, },
env: { env: {
browser: true, browser: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

+4 -2
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -23,10 +23,12 @@ jobs:
bundler-cache: true bundler-cache: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with:
version: 9.3.0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 20
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
+2 -2
View File
@@ -2,7 +2,7 @@
# # # #
# # Linux nightly installer action # # Linux nightly installer action
# # This action will try to install and setup # # This action will try to install and setup
# # chatwoot on an Ubuntu 22.04 machine using # # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script. # # the linux installer script.
# # # #
# # This is set to run daily at midnight. # # This is set to run daily at midnight.
@@ -16,7 +16,7 @@ on:
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-20.04
steps: steps:
- name: get installer - name: get installer
@@ -19,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
-140
View File
@@ -1,140 +0,0 @@
# #
# # This action will publish Chatwoot EE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot EE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
- name: Set Docker Tags
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+19 -101
View File
@@ -5,7 +5,6 @@
# # # #
name: Publish Chatwoot CE docker images name: Publish Chatwoot CE docker images
on: on:
push: push:
branches: branches:
@@ -15,30 +14,20 @@ on:
- v* - v*
workflow_dispatch: workflow_dispatch:
env:
DOCKER_REPO: chatwoot/chatwoot
jobs: jobs:
build: build:
strategy: runs-on: ubuntu-latest
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
env: env:
GIT_REF: ${{ github.head_ref || github.ref_name }} GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Prepare - name: Set up QEMU
run: | uses: docker/setup-qemu-action@v1
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code - name: Strip enterprise code
run: | run: |
@@ -49,97 +38,26 @@ jobs:
run: | run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: Set Docker Tags - name: set docker tag
run: | run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g') echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
if [ "${{ github.ref_name }}" = "master" ]; then
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
else
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
fi
- name: Set up QEMU - name: replace docker tag if master
uses: docker/setup-qemu-action@v3 if: github.ref_name == 'master'
run: |
- name: Set up Docker Buildx echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: docker/login-action@v1
uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest - name: Build and push
id: build uses: docker/build-push-action@v2
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: docker/Dockerfile file: docker/Dockerfile
platforms: ${{ matrix.platform }} platforms: linux/amd64
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} push: true
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true tags: ${{ env.DOCKER_TAG }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: Inspect image
env:
GIT_REF: ${{ github.head_ref || github.ref_name }}
run: |
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
if [ "${{ github.ref_name }}" = "master" ]; then
TAG="${DOCKER_REPO}:latest-ce"
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+10 -3
View File
@@ -1,3 +1,9 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec name: Run Chatwoot CE spec
on: on:
push: push:
@@ -9,10 +15,10 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
services: services:
postgres: postgres:
image: pgvector/pgvector:pg15 image: postgres:15.3
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: '' POSTGRES_PASSWORD: ''
@@ -38,6 +44,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
version: 9
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -47,7 +54,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 20
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
@@ -0,0 +1,89 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: pnpm
run: pnpm install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log
+7 -3
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -19,11 +19,13 @@ jobs:
with: with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 20
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm
@@ -45,3 +47,5 @@ jobs:
- name: Size Check - name: Size Check
run: pnpm run size run: pnpm run size
-40
View File
@@ -1,40 +0,0 @@
name: Test Docker Build
on:
pull_request:
branches:
- develop
- master
workflow_dispatch:
jobs:
test-build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -9
View File
@@ -71,6 +71,8 @@ test/cypress/videos/*
/config/master.key /config/master.key
/config/*.enc /config/*.enc
#ignore files under .vscode directory
.vscode
# yalc for local testing # yalc for local testing
.yalc .yalc
@@ -88,12 +90,3 @@ yarn-debug.log*
# Vite uses dotenv and suggests to ignore local-only env files. See # Vite uses dotenv and suggests to ignore local-only env files. See
# https://vitejs.dev/guide/env-and-mode.html#env-files # https://vitejs.dev/guide/env-and-mode.html#env-files
*.local *.local
# TextEditors & AI Agents config files
.vscode
.claude/settings.local.json
.cursor
# react component
dist
+8 -8
View File
@@ -1,11 +1,11 @@
#!/bin/sh # #!/bin/sh
. "$(dirname "$0")/_/husky.sh" # . "$(dirname "$0")/_/husky.sh"
# lint js and vue files # # lint js and vue files
npx --no-install lint-staged # npx --no-install lint-staged
# lint only staged ruby files that still exist (not deleted) # # lint only staged ruby files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true # git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a
# stage rubocop changes to files # # stage rubocop changes to files
git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true # git diff --name-only --cached | xargs git add
+8 -179
View File
@@ -1,10 +1,7 @@
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
@@ -16,61 +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: 25 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
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:
@@ -78,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
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -97,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
@@ -219,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: false
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
-1
View File
@@ -283,4 +283,3 @@ exclude:
- 'app/javascript/widget/assets/scss/sdk.css' - 'app/javascript/widget/assets/scss/sdk.css'
- 'app/assets/stylesheets/administrate/reset/_normalize.scss' - 'app/assets/stylesheets/administrate/reset/_normalize.scss'
- 'app/javascript/shared/assets/stylesheets/*.scss' - 'app/javascript/shared/assets/stylesheets/*.scss'
- 'app/javascript/dashboard/assets/scss/_woot.scss'
-1
View File
@@ -1 +0,0 @@
../../AGENTS.md
-58
View File
@@ -1,58 +0,0 @@
# Chatwoot Development Guidelines
## Build / Test / Lint
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
## Code Style
- **Ruby**: Follow RuboCop rules (150 character max line length)
- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended)
- **Vue Components**: Use PascalCase
- **Events**: Use camelCase
- **I18n**: No bare strings in templates; use i18n
- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`)
- **Models**: Validate presence/uniqueness, add proper indexes
- **Type Safety**: Use PropTypes in Vue, strong params in Rails
- **Naming**: Use clear, descriptive names with consistent casing
- **Vue API**: Always use Composition API with `<script setup>` at the top
## Styling
- **Tailwind Only**:
- Do not write custom CSS
- Do not use scoped CSS
- Do not use inline styles
- Always use Tailwind utility classes
- **Colors**: Refer to `tailwind.config.js` for color definitions
## General Guidelines
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Don't reference Claude in commit messages
## Project-Specific
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
## Ruby Best Practices
- Use compact `module/class` definitions; avoid nested styles
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+9 -12
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
@@ -33,8 +33,6 @@ 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
@@ -96,7 +94,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.5.2' gem 'slack-ruby-client', '~> 2.2.0'
# 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'
@@ -140,7 +138,9 @@ gem 'procore-sift'
# parse email # parse email
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' # TODO: we might have to fork this gem since 0.3.1 has hard depency on nokogir 1.10.
# and this gem hasn't been updated for a while.
gem 'html2text', git: 'https://github.com/chatwoot/html2text_ruby', branch: 'chatwoot'
# to calculate working hours # to calculate working hours
gem 'working_hours' gem 'working_hours'
@@ -175,11 +175,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'shopify_api'
### Gems required only in specific deployment environments ### ### Gems required only in specific deployment environments ###
############################################################## ##############################################################
@@ -198,6 +193,9 @@ group :development do
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'
@@ -236,7 +234,6 @@ 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.17.1', require: false gem 'simplecov', '0.17.1', require: false
+164 -208
View File
@@ -22,92 +22,87 @@ GIT
devise (>= 4.0.0, < 5.0.0) devise (>= 4.0.0, < 5.0.0)
railties (>= 5.0.0, < 8.0.0) railties (>= 5.0.0, < 8.0.0)
GIT
remote: https://github.com/chatwoot/html2text_ruby
revision: cdbdbbbf898d846d0136d69d688a003c6b26074b
branch: chatwoot
specs:
html2text (0.3.1)
nokogiri (>= 1.13.6)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.5.1) actioncable (7.0.8.5)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) actionmailbox (7.0.8.5)
actionmailbox (7.1.5.1) actionpack (= 7.0.8.5)
actionpack (= 7.1.5.1) activejob (= 7.0.8.5)
activejob (= 7.1.5.1) activerecord (= 7.0.8.5)
activerecord (= 7.1.5.1) activestorage (= 7.0.8.5)
activestorage (= 7.1.5.1) activesupport (= 7.0.8.5)
activesupport (= 7.1.5.1)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.5.1) actionmailer (7.0.8.5)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.5)
actionview (= 7.1.5.1) actionview (= 7.0.8.5)
activejob (= 7.1.5.1) activejob (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
actionpack (7.1.5.1) actionpack (7.0.8.5)
actionview (= 7.1.5.1) actionview (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
nokogiri (>= 1.8.5) rack (~> 2.0, >= 2.2.4)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.1.5.1) actiontext (7.0.8.5)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.5)
activerecord (= 7.1.5.1) activerecord (= 7.0.8.5)
activestorage (= 7.1.5.1) activestorage (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.5.1) actionview (7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.4)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.1.5.1) activejob (7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.5.1) activemodel (7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
activerecord (7.1.5.1) activerecord (7.0.8.5)
activemodel (= 7.1.5.1) activemodel (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
timeout (>= 0.4.0) activerecord-import (1.4.1)
activerecord-import (2.1.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.1.5.1) activestorage (7.0.8.5)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.5)
activejob (= 7.1.5.1) activejob (= 7.0.8.5)
activerecord (= 7.1.5.1) activerecord (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.5.1) mini_mime (>= 1.1.0)
base64 activesupport (7.0.8.5)
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0)
acts-as-taggable-on (12.0.0) acts-as-taggable-on (9.0.1)
activerecord (>= 7.1, < 8.1) activerecord (>= 6.0, < 7.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
@@ -129,7 +124,7 @@ GEM
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.3) ast (2.4.2)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
@@ -155,15 +150,14 @@ GEM
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.0) bigdecimal (3.1.8)
bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.4.1) brakeman (5.4.1)
browser (5.3.1) browser (5.3.1)
builder (3.3.0) builder (3.3.0)
bullet (8.0.7) bullet (7.0.7)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundle-audit (0.1.0) bundle-audit (0.1.0)
@@ -175,8 +169,8 @@ GEM
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.4)
connection_pool (2.5.3) connection_pool (2.4.1)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -190,10 +184,16 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.4.1) datadog-ci (0.8.3)
ddtrace (0.48.0)
ffi (~> 1.0)
msgpack msgpack
date (3.3.4)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
msgpack
debase-ruby_core_source (3.3.1)
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -204,10 +204,10 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise_token_auth (1.2.5) devise_token_auth (1.2.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 8.1) rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
@@ -220,7 +220,6 @@ GEM
railties (>= 6.1) railties (>= 6.1)
down (5.4.0) down (5.4.0)
addressable (~> 2.8) addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0) dry-cli (1.1.0)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
@@ -232,7 +231,6 @@ GEM
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1) execjs (2.8.1)
facebook-messenger (2.0.1) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
@@ -263,10 +261,7 @@ GEM
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
ffi (1.17.2) ffi (1.16.3)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@@ -284,8 +279,7 @@ GEM
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.22.2) gli (2.21.1)
ostruct
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
gmail_xoauth (0.4.3) gmail_xoauth (0.4.3)
@@ -327,13 +321,16 @@ GEM
google-cloud-translate-v3 (0.10.0) google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.7) google-protobuf (3.25.5)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
googleapis-common-protos (1.6.0) googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7) googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41) grpc (~> 1.41)
googleapis-common-protos-types (1.20.0) googleapis-common-protos-types (1.14.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (~> 3.18)
googleauth (1.11.2) googleauth (1.11.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1) google-cloud-env (~> 2.1)
@@ -343,17 +340,17 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.72.0) grpc (1.62.0)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-arm64-darwin) grpc (1.62.0-arm64-darwin)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-darwin) grpc (1.62.0-x86_64-darwin)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-linux) grpc (1.62.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0) google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
@@ -361,11 +358,8 @@ GEM
ruby2ruby (~> 2.4) ruby2ruby (~> 2.4)
ruby_parser (~> 3.10) ruby_parser (~> 3.10)
hana (1.3.7) hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0) hashdiff (1.1.0)
hashie (5.0.0) hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1) http (5.1.1)
addressable (~> 2.8) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
@@ -379,7 +373,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.7) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -387,8 +381,6 @@ GEM
io-console (0.6.0) io-console (0.6.0)
irb (1.7.2) irb (1.7.2)
reline (>= 0.3.6) reline (>= 0.3.6)
iso-639 (0.3.8)
csv
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
@@ -397,7 +389,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.12.0) json (2.6.3)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -432,13 +424,21 @@ GEM
faraday-multipart faraday-multipart
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
launchy (>= 2.2, < 3) launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0) liquid (5.4.0)
listen (3.8.0) listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
@@ -446,13 +446,13 @@ GEM
llhttp-ffi (0.4.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
logger (1.7.0) logger (1.6.0)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.23.1) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -471,11 +471,11 @@ GEM
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.7)
minitest (5.25.5) minitest (5.25.1)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.7.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
@@ -486,7 +486,7 @@ GEM
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.20) net-imap (0.4.17)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -502,14 +502,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.8) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -524,9 +524,6 @@ GEM
rack (>= 1.2, < 4) rack (>= 1.2, < 4)
snaky_hash (~> 2.0) snaky_hash (~> 2.0)
version_gem (~> 1.1) version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
@@ -545,17 +542,14 @@ GEM
openssl (3.2.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.1) parallel (1.23.0)
parallel (1.27.0) parser (3.2.2.1)
parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
pg (1.5.3) pg (1.5.3)
pg_search (2.3.6) pg_search (2.3.6)
activerecord (>= 5.2) activerecord (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
pgvector (0.1.1) pgvector (0.1.1)
prism (1.4.0)
procore-sift (1.0.0) procore-sift (1.0.0)
activerecord (>= 6.1) activerecord (>= 6.1)
pry (0.14.2) pry (0.14.2)
@@ -570,7 +564,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.15) rack (2.2.10)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -584,43 +578,37 @@ GEM
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rackup (1.0.1) rails (7.0.8.5)
rack (< 3) actioncable (= 7.0.8.5)
webrick actionmailbox (= 7.0.8.5)
rails (7.1.5.1) actionmailer (= 7.0.8.5)
actioncable (= 7.1.5.1) actionpack (= 7.0.8.5)
actionmailbox (= 7.1.5.1) actiontext (= 7.0.8.5)
actionmailer (= 7.1.5.1) actionview (= 7.0.8.5)
actionpack (= 7.1.5.1) activejob (= 7.0.8.5)
actiontext (= 7.1.5.1) activemodel (= 7.0.8.5)
actionview (= 7.1.5.1) activerecord (= 7.0.8.5)
activejob (= 7.1.5.1) activestorage (= 7.0.8.5)
activemodel (= 7.1.5.1) activesupport (= 7.0.8.5)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.5.1) railties (= 7.0.8.5)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (~> 1.14)
railties (7.1.5.1) railties (7.0.8.5)
actionpack (= 7.1.5.1) actionpack (= 7.0.8.5)
activesupport (= 7.1.5.1) activesupport (= 7.0.8.5)
irb method_source
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0)
zeitwerk (~> 2.6) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
@@ -632,7 +620,7 @@ GEM
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
regexp_parser (2.10.0) regexp_parser (2.8.0)
reline (0.3.6) reline (0.3.6)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
@@ -652,7 +640,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.4.1) rexml (3.3.9)
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.2)
@@ -672,40 +660,30 @@ GEM
rspec-support (3.13.1) rspec-support (3.13.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.75.6) rubocop (1.50.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 1.8, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.44.1) rubocop-ast (1.28.1)
parser (>= 3.3.7.2) parser (>= 3.2.1.0)
prism (~> 1.4) rubocop-capybara (2.18.0)
rubocop-factory_bot (2.27.1) rubocop (~> 1.41)
lint_roller (~> 1.1) rubocop-performance (1.17.1)
rubocop (~> 1.72, >= 1.72.1) rubocop (>= 1.7.0, < 2.0)
rubocop-performance (1.25.0) rubocop-ast (>= 0.4.0)
lint_roller (~> 1.1) rubocop-rails (2.19.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0) rubocop-rspec (2.21.0)
rubocop-rspec (3.6.0) rubocop (~> 1.33)
lint_roller (~> 1.1) rubocop-capybara (~> 2.17)
rubocop (~> 1.72, >= 1.72.1)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
@@ -732,7 +710,6 @@ GEM
parser parser
scss_lint (0.60.0) scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5) sass (~> 3.5, >= 3.5.5)
securerandom (0.4.1)
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
@@ -747,17 +724,6 @@ GEM
sentry-ruby (~> 5.19.0) sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.17.0) sexp_processor (4.17.0)
shopify_api (14.8.0)
activesupport
concurrent-ruby
hash_diff
httparty
jwt
oj
openssl
securerandom
sorbet-runtime
zeitwerk (~> 2.5)
shoulda-matchers (5.3.0) shoulda-matchers (5.3.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (7.3.1) sidekiq (7.3.1)
@@ -780,17 +746,15 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.2) simplecov-html (0.10.2)
slack-ruby-client (2.5.2) slack-ruby-client (2.2.0)
faraday (>= 2.0) faraday (>= 2.0)
faraday-mashify faraday-mashify
faraday-multipart faraday-multipart
gli gli
hashie hashie
logger
snaky_hash (2.0.1) snaky_hash (2.0.1)
hashie hashie
version_gem (~> 1.1, >= 1.1.1) version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1) spring (4.1.1)
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
@@ -813,7 +777,7 @@ GEM
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.4.3) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.77.0) twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -831,11 +795,9 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (3.1.4) unicode-display_width (2.4.2)
unicode-emoji (~> 4.0, >= 4.0.4) uniform_notifier (1.16.0)
unicode-emoji (4.0.4) uri (0.13.0)
uniform_notifier (1.17.0)
uri (1.0.3)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@@ -862,9 +824,7 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) websocket-driver (0.7.6)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
@@ -932,9 +892,8 @@ DEPENDENCIES
haikunator haikunator
hairtrigger hairtrigger
hashie hashie
html2text html2text!
image_processing image_processing
iso-639
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
@@ -970,7 +929,7 @@ DEPENDENCIES
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.1) rails (~> 7.0.8.4)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
@@ -979,23 +938,20 @@ DEPENDENCIES
rspec-rails (>= 6.1.5) rspec-rails (>= 6.1.5)
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rubocop-factory_bot
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-openai
scout_apm scout_apm
scss_lint scss_lint
seed_dump seed_dump
sentry-rails (>= 5.19.0) sentry-rails (>= 5.19.0)
sentry-ruby sentry-ruby
sentry-sidekiq (>= 5.19.0) sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers shoulda-matchers
sidekiq (>= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.5.2) slack-ruby-client (~> 2.2.0)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
@@ -1017,7 +973,7 @@ DEPENDENCIES
working_hours working_hours
RUBY VERSION RUBY VERSION
ruby 3.4.4p34 ruby 3.3.3p89
BUNDLED WITH BUNDLED WITH
2.5.16 2.5.16
+1 -8
View File
@@ -41,15 +41,8 @@ run:
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -59,4 +52,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run 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
+47 -64
View File
@@ -1,11 +1,22 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/> ## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
___ ___
# Chatwoot # Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a> <a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
@@ -20,71 +31,41 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a> <a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
<p> Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> ## Features
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
--- Chatwoot supports the following conversation channels:
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels. - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
### ✨ Captain AI Agent for Support And more.
Supercharge your support with Captain, Chatwoots AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs). Other features include:
### 💬 Omnichannel Support Desk
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
### 📚 Help center portal
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
### 🗂️ Other features
#### Collaboration & Productivity
- Private Notes and @mentions for internal team discussions.
- Labels to organize and categorize conversations.
- Keyboard Shortcuts and a Command Bar for quick navigation.
- Canned Responses to reply faster to frequently asked questions.
- Auto-Assignment to route conversations based on agent availability.
- Multi-lingual Support to serve customers in multiple languages.
- Custom Views and Filters for better inbox organization.
- Business Hours and Auto-Responders to manage response expectations.
- Teams and Automation tools for scaling support workflows.
- Agent Capacity Management to balance workload across the team.
#### Customer Data & Segmentation
- Contact Management with profiles and interaction history.
- Contact Segments and Notes for targeted communication.
- Campaigns to proactively engage customers.
- Custom Attributes for storing additional customer data.
- Pre-Chat Forms to collect user information before starting conversations.
#### Integrations
- Slack Integration to manage conversations directly from Slack.
- Dialogflow Integration for chatbot automation.
- Dashboard Apps to embed internal tools within Chatwoot.
- Shopify Integration to view and manage customer orders right within Chatwoot.
- Use Google Translate to translate messages from your customers in realtime.
- Create and manage Linear tickets within Chatwoot.
#### Reports & Insights
- Live View of ongoing conversations for real-time monitoring.
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
- CSAT Reports to measure customer satisfaction.
- Downloadable Reports for offline analysis and reporting.
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation ## Documentation
@@ -126,15 +107,17 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server. If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2024, Chatwoot Inc - Released under the MIT License.
+4 -4
View File
@@ -2,13 +2,13 @@ Chatwoot is looking forward to working with security researchers worldwide to ke
## Reporting a Vulnerability ## Reporting a Vulnerability
We use Github to track the security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://github.com/chatwoot/chatwoot/security/advisories/new). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com. If you have any questions about the process, contact security@chatwoot.com.
Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Github as quickly as possible. Please try your best to describe a clear and realistic impact for your report, and please don't open any public issues on GitHub or social media; we're doing our best to respond through Huntr as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [Github](https://github.com/chatwoot/chatwoot/security/advisories/new) > Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
## Supported versions ## Supported versions
| Version | Supported | | Version | Supported |
@@ -48,7 +48,7 @@ We consider the following out of scope, though there may be exceptions.
- Brute force attacks - Brute force attacks
- DNSSEC - DNSSEC
If you are unsure about the scope, please create a [report](https://github.com/chatwoot/chatwoot/security/advisories/new). If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks ## Thanks
+1 -1
View File
@@ -1 +1 @@
3.2.0 3.0.0
+2 -2
View File
@@ -48,7 +48,7 @@
"size": "basic" "size": "basic"
} }
}, },
"stack": "heroku-24", "stack": "heroku-20",
"image": "heroku/ruby", "image": "heroku/ruby",
"addons": [ "addons": [
{ {
@@ -58,7 +58,7 @@
"plan": "heroku-postgresql:essential-0" "plan": "heroku-postgresql:essential-0"
} }
], ],
"stack": "heroku-24", "stack": "heroku-20",
"buildpacks": [ "buildpacks": [
{ {
"url": "heroku/nodejs" "url": "heroku/nodejs"
+1 -1
View File
@@ -54,7 +54,7 @@ class ContactMergeAction
# attributes in base contact are given preference # attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes) merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.reload.destroy! @mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)]) tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes) @base_contact.update!(merged_attributes)
@@ -1,15 +1,15 @@
button:not(.reset-base), button,
input[type='button']:not(.reset-base), input[type="button"],
input[type='reset']:not(.reset-base), input[type="reset"],
input[type='submit']:not(.reset-base), input[type="submit"],
.button:not(.reset-base) { .button {
appearance: none; appearance: none;
background-color: $color-woot; background-color: $color-woot;
border: 0; border: 0;
border-radius: $base-border-radius; border-radius: $base-border-radius;
color: $white; color: $white;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-block;
font-size: $font-size-small; font-size: $font-size-small;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
@@ -10,6 +10,7 @@
.icon-container { .icon-container {
margin-right: 2px; margin-right: 2px;
} }
.value-container { .value-container {
+25 -1
View File
@@ -32,7 +32,14 @@ class AccountBuilder
end end
def validate_email def validate_email
Account::SignUpEmailValidationService.new(@email).perform raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
true
else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end end
def validate_user def validate_user
@@ -74,4 +81,21 @@ class AccountBuilder
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end end
@@ -9,7 +9,7 @@ class Campaigns::CampaignConversationBuilder
@contact_inbox.lock! @contact_inbox.lock!
# We won't send campaigns if a conversation is already present # We won't send campaigns if a conversation is already present
raise 'Conversation already present' if @contact_inbox.reload.conversations.present? raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params) @conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
+2 -39
View File
@@ -59,47 +59,10 @@ 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
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
retry
end
def update_old_contact_inbox
# The race condition occurs when theres a contact inbox with the
# same source ID but linked to a different contact. This can happen
# if the agent updates the contacts email or phone number, or
# if the contact is merged with another.
#
# We update the old contact inbox source_id to a random value to
# avoid disrupting the current flow. However, the root cause of
# this issue is a flaw in the contact inbox model design.
# Contact inbox is essentially tracking a session and is not
# needed for non-live chat channels.
raise ActiveRecord::RecordNotUnique unless allowed_channels?
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
return if contact_inbox.blank?
contact_inbox.update!(source_id: new_source_id)
end
def new_source_id
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
"whatsapp:#{@source_id}#{rand(100)}"
else
"#{rand(10)}#{@source_id}"
end
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end end
end end
@@ -63,33 +63,9 @@ class ContactInboxWithContactBuilder
contact = find_contact_by_identifier(contact_attributes[:identifier]) contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
contact contact
end end
def instagram_channel?
inbox.channel_type == 'Channel::Instagram'
end
# There might be existing contact_inboxes created through Channel::FacebookPage
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
# while still reusing contacts if found in Facebook channels so that we can create
# new conversations with the same contact.
def find_contact_by_instagram_source_id(instagram_id)
return if instagram_id.blank?
existing_contact_inbox = ContactInbox.joins(:inbox)
.where(source_id: instagram_id)
.where(
'inboxes.channel_type = ? AND inboxes.account_id = ?',
'Channel::FacebookPage',
account.id
).first
existing_contact_inbox&.contact
end
def find_contact_by_identifier(identifier) def find_contact_by_identifier(identifier)
return if identifier.blank? return if identifier.blank?
@@ -1,180 +0,0 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue StandardError => e
handle_error(e)
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
find_conversation_scope.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end
end
def find_conversation_scope
Conversation.where(conversation_params)
end
def find_or_build_for_multiple_conversations
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
# Duplicate webhook events may be sent for the same message
# when a user is connected to the Instagram account through both Messenger and Instagram login.
# There is chance for echo events to be sent for the same message.
# Therefore, we need to check if the message already exists before creating it.
return if message_already_exists?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_conversation_attributes
))
end
def additional_conversation_attributes
{}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present?
end
def find_message_by_source_id(source_id)
return unless source_id
@message = Message.find_by(source_id: source_id)
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
def handle_error(error)
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
true
end
# Abstract methods to be implemented by subclasses
def get_story_object_from_source_id(source_id)
raise NotImplementedError
end
end
@@ -1,42 +1,200 @@
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder # This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false) def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo) super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end end
private private
def get_story_object_from_source_id(source_id) def attachments
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" @messaging[:message][:attachments] || {}
response = HTTParty.get(url)
return JSON.parse(response.body).with_indifferent_access if response.success?
# Create message first if it doesn't exist
@message ||= conversation.messages.create!(message_params)
handle_error_response(response)
nil
end end
def handle_error_response(response) def message_type
parsed_response = JSON.parse(response.body) @outgoing_echo ? :outgoing : :incoming
error_code = parsed_response.dig('error', 'code') end
# https://developers.facebook.com/docs/messenger-platform/error-codes def message_identifier
# Access token has expired or become invalid. message[:mid]
channel.authorization_error! if error_code == 190 end
# There was a problem scraping data from the provided link. def message_source_id
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 @outgoing_echo ? recipient_id : sender_id
if error_code == 1_609_005 end
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) def message_is_unsupported?
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= set_conversation_based_on_inbox_config
end
def instagram_direct_message_conversation
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def set_conversation_based_on_inbox_config
if @inbox.lock_to_single_conversation
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
else
find_or_build_for_multiple_conversations
end end
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
end end
def base_uri def find_or_build_for_multiple_conversations
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
return build_conversation if last_conversation.nil?
last_conversation
end end
def message_content
@messaging[:message][:text]
end
def story_reply_attributes
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
end
def message_reply_attributes
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
save_story_id
attachments.each do |attachment|
process_attachment(attachment)
end
end
def save_story_id
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
params = {
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
content_attributes: {
in_reply_to_external_id: message_reply_attributes
}
}
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end end
@@ -1,33 +0,0 @@
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
def initialize(messaging, inbox, outgoing_echo: false)
super(messaging, inbox, outgoing_echo: outgoing_echo)
end
private
def get_story_object_from_source_id(source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
def find_conversation_scope
Conversation.where(conversation_params)
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
end
def additional_conversation_attributes
{ type: 'instagram_direct_message' }
end
end
@@ -68,8 +68,20 @@ class Messages::Messenger::MessageBuilder
message.save! message.save!
end end
# This is a placeholder method to be overridden by child classes def get_story_object_from_source_id(source_id)
def get_story_object_from_source_id(_source_id) k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
k.get_object(source_id, fields: %w[story from]) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{} {}
end end
-2
View File
@@ -25,8 +25,6 @@ class NotificationBuilder
def build_notification def build_notification
# Create conversation_creation notification only if user is subscribed to it # Create conversation_creation notification only if user is subscribed to it
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
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
user.notifications.create!( user.notifications.create!(
notification_type: notification_type, notification_type: notification_type,
@@ -2,38 +2,52 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!] pattr_initialize [:account!, :params!]
def build def build
load_data set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report prepare_report
end end
private private
attr_reader :conversations_count, :resolved_count, def set_grouped_conversations_count
:avg_resolution_time, :avg_first_response_time, :avg_reply_time @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('assignee_id').count
def fetch_conversations_count
account.conversations.where(created_at: range).group('assignee_id').count
end end
def prepare_report def set_grouped_avg_resolution_time
account.account_users.map do |account_user| @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
build_agent_stats(account_user)
end
end end
def build_agent_stats(account_user) def set_grouped_avg_first_response_time
user_id = account_user.user_id @grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
{ end
id: user_id,
conversations_count: conversations_count[user_id] || 0, def set_grouped_avg_reply_time
resolved_conversations_count: resolved_count[user_id] || 0, @grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
avg_resolution_time: avg_resolution_time[user_id],
avg_first_response_time: avg_first_response_time[user_id],
avg_reply_time: avg_reply_time[user_id]
}
end end
def group_by_key def group_by_key
:user_id :user_id
end end
def reporting_events
@reporting_events ||= Current.account.reporting_events.where(created_at: range)
end
def prepare_report
account.account_users.each_with_object([]) do |account_user, arr|
arr << {
id: account_user.user_id,
conversations_count: @grouped_conversations_count[account_user.user_id],
avg_resolution_time: @grouped_avg_resolution_time[account_user.user_id],
avg_first_response_time: @grouped_avg_first_response_time[account_user.user_id],
avg_reply_time: @grouped_avg_reply_time[account_user.user_id]
}
end
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end
end end
@@ -1,50 +1,17 @@
class V2::Reports::BaseSummaryBuilder class V2::Reports::BaseSummaryBuilder
include DateRangeHelper include DateRangeHelper
def build
load_data
prepare_report
end
private private
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
def fetch_conversations_count
# Override this method
end
def fetch_average_time(event_name)
get_grouped_average(reporting_events.where(name: event_name))
end
def fetch_resolved_count
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
end
def group_by_key def group_by_key
# Override this method # Override this method
end end
def prepare_report
# Override this method
end
def get_grouped_average(events) def get_grouped_average(events)
events.group(group_by_key).average(average_value_key) events.group(group_by_key).average(average_value_key)
end end
def average_value_key def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value params[:business_hours].present? ? :value_in_business_hours : :value
end end
end end
@@ -1,50 +0,0 @@
class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!]
def build
load_data
prepare_report
end
private
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
@resolved_count = fetch_resolved_count
@avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
@avg_reply_time = fetch_average_time('reply_time')
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
def prepare_report
account.inboxes.map do |inbox|
build_inbox_stats(inbox)
end
end
def build_inbox_stats(inbox)
{
id: inbox.id,
conversations_count: conversations_count[inbox.id] || 0,
resolved_conversations_count: resolved_count[inbox.id] || 0,
avg_resolution_time: avg_resolution_time[inbox.id],
avg_first_response_time: avg_first_response_time[inbox.id],
avg_reply_time: avg_reply_time[inbox.id]
}
end
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
+34 -22
View File
@@ -1,37 +1,49 @@
class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
pattr_initialize [:account!, :params!] pattr_initialize [:account!, :params!]
def build
set_grouped_conversations_count
set_grouped_avg_reply_time
set_grouped_avg_first_response_time
set_grouped_avg_resolution_time
prepare_report
end
private private
attr_reader :conversations_count, :resolved_count, def set_grouped_conversations_count
:avg_resolution_time, :avg_first_response_time, :avg_reply_time @grouped_conversations_count = Current.account.conversations.where(created_at: range).group('team_id').count
end
def fetch_conversations_count def set_grouped_avg_resolution_time
account.conversations.where(created_at: range).group(:team_id).count @grouped_avg_resolution_time = get_grouped_average(reporting_events.where(name: 'conversation_resolved'))
end
def set_grouped_avg_first_response_time
@grouped_avg_first_response_time = get_grouped_average(reporting_events.where(name: 'first_response'))
end
def set_grouped_avg_reply_time
@grouped_avg_reply_time = get_grouped_average(reporting_events.where(name: 'reply_time'))
end end
def reporting_events def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) @reporting_events ||= Current.account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
end
end
def build_team_stats(team)
{
id: team.id,
conversations_count: conversations_count[team.id] || 0,
resolved_conversations_count: resolved_count[team.id] || 0,
avg_resolution_time: avg_resolution_time[team.id],
avg_first_response_time: avg_first_response_time[team.id],
avg_reply_time: avg_reply_time[team.id]
}
end end
def group_by_key def group_by_key
'conversations.team_id' 'conversations.team_id'
end end
def prepare_report
account.teams.each_with_object([]) do |team, arr|
arr << {
id: team.id,
conversations_count: @grouped_conversations_count[team.id],
avg_resolution_time: @grouped_avg_resolution_time[team.id],
avg_first_response_time: @grouped_avg_first_response_time[team.id],
avg_reply_time: @grouped_avg_reply_time[team.id]
}
end
end
end end
@@ -28,7 +28,7 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end end
def object_scope def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) scope.reporting_events.where(name: event_name, created_at: range)
end end
def reporting_events def reporting_events
+7 -10
View File
@@ -2,9 +2,10 @@ class RoomChannel < ApplicationCable::Channel
def subscribed def subscribed
# TODO: should we only do ensure stream if current account is present? # TODO: should we only do ensure stream if current account is present?
# for now going ahead with guard clauses in update_subscription and broadcast_presence # for now going ahead with guard clauses in update_subscription and broadcast_presence
ensure_stream
current_user current_user
current_account current_account
ensure_stream
update_subscription update_subscription
broadcast_presence broadcast_presence
end end
@@ -21,12 +22,12 @@ class RoomChannel < ApplicationCable::Channel
data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) } data = { account_id: @current_account.id, users: ::OnlineStatusTracker.get_available_users(@current_account.id) }
data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User data[:contacts] = ::OnlineStatusTracker.get_available_contacts(@current_account.id) if @current_user.is_a? User
ActionCable.server.broadcast(pubsub_token, { event: 'presence.update', data: data }) ActionCable.server.broadcast(@pubsub_token, { event: 'presence.update', data: data })
end end
def ensure_stream def ensure_stream
stream_from pubsub_token @pubsub_token = params[:pubsub_token]
stream_from "account_#{@current_account.id}" if @current_account.present? && @current_user.is_a?(User) stream_from @pubsub_token
end end
def update_subscription def update_subscription
@@ -35,15 +36,11 @@ class RoomChannel < ApplicationCable::Channel
::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id) ::OnlineStatusTracker.update_presence(@current_account.id, @current_user.class.name, @current_user.id)
end end
def pubsub_token
@pubsub_token ||= params[:pubsub_token]
end
def current_user def current_user
@current_user ||= if params[:user_id].blank? @current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: pubsub_token).contact ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
else else
User.find_by!(pubsub_token: pubsub_token, id: params[:user_id]) User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end end
end end
@@ -29,11 +29,6 @@ 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
private private
def agent_bot def agent_bot
@@ -42,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {}) params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end end
def process_avatar_from_url def process_avatar_from_url
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def allowed_agent_params def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline] [:name, :email, :name, :role, :availability, :auto_offline]
end end
def agent_params def agent_params
@@ -68,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: [] }]
@@ -1,6 +1,3 @@
# TODO : Move this to inboxes controller and deprecate this controller
# No need to retain this controller as we could handle everything centrally in inboxes controller
class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts::BaseController
before_action :authorize_request before_action :authorize_request
@@ -1,17 +1,17 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index def index
# Start with all conversations for this contact @conversations = Current.account.conversations.includes(
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(contact_id: @contact.id) ).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20)
end
# Apply permission-based filtering using the existing service private
conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
@conversations = conversations.order(last_activity_at: :desc).limit(20) def inbox_ids
if Current.user.administrator? || Current.user.agent?
Current.user.assigned_inboxes.pluck(:id)
else
[]
end
end end
end end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
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_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts(contacts) @contacts = contacts.page(@current_page)
end end
def show; end def show; end
@@ -68,7 +68,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contacts = fetch_contacts(contacts) @contacts = fetch_contacts(contacts)
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator, CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -163,16 +162,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact.custom_attributes @contact.custom_attributes
end end
def contact_additional_attributes
return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes]
@contact.additional_attributes
end
def contact_update_params def contact_update_params
permitted_params.except(:custom_attributes, :avatar_url) # we want the merged custom attributes not the original one
.merge({ custom_attributes: contact_custom_attributes }) permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
.merge({ additional_attributes: contact_additional_attributes })
end end
def set_include_contact_inboxes def set_include_contact_inboxes
@@ -1,6 +1,4 @@
class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController
before_action :ensure_api_inbox, only: :update
def index def index
@messages = message_finder.perform @messages = message_finder.perform
end end
@@ -13,11 +11,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
def update
Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform
@message = message
end
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
@@ -28,9 +21,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def retry def retry
return if message.blank? return if message.blank?
service = Messages::StatusUpdateService.new(message, 'sent') message.update!(status: :sent, content_attributes: {})
service.perform
message.update!(content_attributes: {})
::SendReplyJob.perform_later(message.id) ::SendReplyJob.perform_later(message.id)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
@@ -65,16 +56,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end end
def permitted_params def permitted_params
params.permit(:id, :target_language, :status, :external_error) params.permit(:id, :target_language)
end end
def already_translated_content_available? def already_translated_content_available?
message.translations.present? && message.translations[permitted_params[:target_language]].present? message.translations.present? && message.translations[permitted_params[:target_language]].present?
end end
# API inbox check
def ensure_api_inbox
# Only API inboxes can update messages
render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api?
end
end end
@@ -6,8 +6,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create] before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@conversations = result[:conversations] @conversations = result[:conversations]
@@ -26,12 +24,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def attachments def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments @attachments = @conversation.attachments
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
end end
def show; end def show; end
@@ -48,12 +41,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def filter def filter
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations] @conversations = result[:conversations]
@conversations_count = result[:count] @conversations_count = result[:count]
rescue CustomExceptions::CustomFilter::InvalidAttribute, rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator, CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@@ -124,12 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params
@@ -137,10 +123,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
params.permit(:priority) params.permit(:priority)
end end
def attachment_params
params.permit(:page)
end
def update_last_seen_on_conversation(last_seen_at, update_assignee) def update_last_seen_on_conversation(last_seen_at, update_assignee)
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at) @conversation.update_column(:agent_last_seen_at, last_seen_at)
@@ -1,6 +1,6 @@
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :fetch_custom_filters, only: [:index] before_action :fetch_custom_filters, except: [:create]
before_action :fetch_custom_filter, only: [:show, :update, :destroy] before_action :fetch_custom_filter, only: [:show, :update, :destroy]
DEFAULT_FILTER_TYPE = 'conversation'.freeze DEFAULT_FILTER_TYPE = 'conversation'.freeze
@@ -9,8 +9,8 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
def show; end def show; end
def create def create
@custom_filter = Current.account.custom_filters.create!( @custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(user: Current.user) permitted_payload.merge(account_id: Current.account.id)
) )
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid? render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
end end
@@ -27,16 +27,14 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
private private
def fetch_custom_filters def fetch_custom_filters
@custom_filters = Current.account.custom_filters.where( @custom_filters = current_user.custom_filters.where(
user: Current.user, account_id: Current.account.id,
filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE
) )
end end
def fetch_custom_filter def fetch_custom_filter
@custom_filter = Current.account.custom_filters.where( @custom_filter = @custom_filters.find(permitted_params[:id])
user: Current.user
).find(permitted_params[:id])
end end
def permitted_payload def permitted_payload
@@ -10,7 +10,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def create def create
authorize @inbox, :create? authorize @inbox, :create?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@inbox.add_members(agents_to_be_added_ids) agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
end end
fetch_updated_agents fetch_updated_agents
end end
@@ -24,7 +24,7 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
def destroy def destroy
authorize @inbox, :destroy? authorize @inbox, :destroy?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@inbox.remove_members(params[:user_ids]) params[:user_ids].map { |user_id| @inbox.remove_member(user_id) }
end end
head :ok head :ok
end end
@@ -41,8 +41,8 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
# the missing ones are the agents which are to be deleted from the inbox # the missing ones are the agents which are to be deleted from the inbox
# the new ones are the agents which are to be added to the inbox # the new ones are the agents which are to be added to the inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@inbox.add_members(agents_to_be_added_ids) agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
@inbox.remove_members(agents_to_be_removed_ids) agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
end end
end end
@@ -42,9 +42,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
inbox_params = permitted_params.except(:channel, :csat_config) @inbox.update!(permitted_params.except(:channel))
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours update_inbox_working_hours
update_channel if channel_update_required? update_channel if channel_update_required?
end end
@@ -123,22 +121,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save! @inbox.channel.save!
end end
def format_csat_config(config)
{
display_type: config['display_type'] || 'emoji',
message: config['message'] || '',
survey_rules: {
operator: config.dig('survey_rules', 'operator') || 'contains',
values: config.dig('survey_rules', 'values') || []
}
}
end
def inbox_attributes def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name]
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
end end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])
@@ -1,30 +0,0 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
redirect_url = instagram_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/instagram/callback",
scope: REQUIRED_SCOPES.join(','),
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -0,0 +1,53 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
def proxy
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
end
def request_url
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def permitted_params
params.permit(:method, :route, body: {})
end
end
@@ -1,11 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def teams def teams
teams = linear_processor_service.teams teams = linear_processor_service.teams
@@ -94,11 +88,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end end
def permitted_params def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id, params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
label_ids: [])
end
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end end
end end
@@ -1,111 +0,0 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
customers = fetch_customers
return render json: { orders: [] } if customers.empty?
orders = fetch_orders(customers.first['id'])
render json: { orders: orders }
rescue ShopifyAPI::Errors::HttpResponseError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@hook.destroy!
head :ok
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
end
def fetch_hook
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
query = []
query << "email:#{contact.email}" if contact.email.present?
query << "phone:#{contact.phone_number}" if contact.phone_number.present?
shopify_client.get(
path: 'customers/search.json',
query: {
query: query.join(' OR '),
fields: 'id,email,phone'
}
).body['customers'] || []
end
def fetch_orders(customer_id)
orders = shopify_client.get(
path: 'orders.json',
query: {
customer_id: customer_id,
status: 'any',
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
}
).body['orders'] || []
orders.map do |order|
order.merge('admin_url' => "https://#{@hook.reference_id}/admin/orders/#{order['id']}")
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact
return unless contact.blank? || (contact.email.blank? && contact.phone_number.blank?)
render json: { error: 'Contact information missing' },
status: :unprocessable_entity
end
end
@@ -9,6 +9,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portals = Current.account.portals @portals = Current.account.portals
end end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show def show
@all_articles = @portal.articles @all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale]) @articles = @all_articles.search(locale: params[:locale])
@@ -80,6 +85,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
{ channel_web_widget_id: inbox.channel.id } { channel_web_widget_id: inbox.channel.id }
end end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page def set_current_page
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
@@ -15,10 +15,6 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
@result = search('Message') @result = search('Message')
end end
def articles
@result = search('Article')
end
private private
def search(search_type) def search(search_type)
@@ -9,14 +9,14 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@team_members = @team.add_members(members_to_be_added_ids) @team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
end end
end end
def update def update
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@team.add_members(members_to_be_added_ids) members_to_be_added_ids.each { |user_id| @team.add_member(user_id) }
@team.remove_members(members_to_be_removed_ids) members_to_be_removed_ids.each { |user_id| @team.remove_member(user_id) }
end end
@team_members = @team.members @team_members = @team.members
render action: 'create' render action: 'create'
@@ -24,7 +24,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@team.remove_members(params[:user_ids]) params[:user_ids].map { |user_id| @team.remove_member(user_id) }
end end
head :ok head :ok
end end
@@ -44,9 +44,8 @@ class Api::V1::AccountsController < Api::BaseController
end end
def update def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
@account.custom_attributes.merge!(custom_attributes_params) @account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save! @account.save!
end end
@@ -84,17 +83,13 @@ class Api::V1::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end end
def custom_attributes_params def custom_attributes_params
params.permit(:industry, :company_size, :timezone) params.permit(:industry, :company_size, :timezone)
end end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end
@@ -17,7 +17,7 @@ class Api::V1::ProfilesController < Api::BaseController
def avatar def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached? @user.avatar.attachment.destroy! if @user.avatar.attached?
@user.reload head :ok
end end
def auto_offline def auto_offline
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
@@ -2,15 +2,6 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact skip_before_action :set_contact
def index def index
account = @web_widget.inbox.account @campaigns = @web_widget.inbox.campaigns.where(enabled: true)
@campaigns = if account.feature_enabled?('campaigns')
@web_widget
.inbox
.campaigns
.where(enabled: true, account_id: account.id)
.includes(:sender)
else
[]
end
end end
end end
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
skip_before_action :set_contact skip_before_action :set_contact
def index def index
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob }) @inbox_members = @web_widget.inbox.inbox_members.includes(:user)
end end
end end
@@ -1,64 +0,0 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
before_action :check_authorization
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count,
pending: @conversations.pending.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def check_authorization
authorize :report, :view?
end
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def check_authorization def check_authorization
authorize :report, :view? return if Current.account_user.administrator?
raise Pundit::NotAuthorizedError
end end
def common_params def common_params
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end end
end end
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox] before_action :prepare_builder_params, only: [:agent, :team]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -10,10 +10,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::TeamSummaryBuilder) render_report_with(V2::Reports::TeamSummaryBuilder)
end end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
end
private private
def check_authorization def check_authorization
@@ -30,7 +26,8 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def render_report_with(builder_class) def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params) builder = builder_class.new(account: Current.account, params: @builder_params)
render json: builder.build data = builder.build
render json: data
end end
def permitted_params def permitted_params
@@ -54,7 +54,7 @@ class Api::V2::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end end
def check_signup_enabled def check_signup_enabled
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = { BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes], 'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update],
'api/v1/accounts/conversations/messages' => ['create'], 'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'] 'api/v1/accounts/conversations/assignments' => ['create']
}.freeze }.freeze
@@ -1,74 +0,0 @@
module InstagramConcern
extend ActiveSupport::Concern
def instagram_client
::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://api.instagram.com',
authorize_url: 'https://api.instagram.com/oauth/authorize',
token_url: 'https://api.instagram.com/oauth/access_token',
auth_scheme: :request_body,
token_method: :post
}
)
end
private
def client_id
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
end
def exchange_for_long_lived_token(short_lived_token)
endpoint = 'https://graph.instagram.com/access_token'
params = {
grant_type: 'ig_exchange_token',
client_secret: client_secret,
access_token: short_lived_token,
client_id: client_id
}
make_api_request(endpoint, params, 'Failed to exchange token')
end
def fetch_instagram_user_details(access_token)
endpoint = 'https://graph.instagram.com/v22.0/me'
params = {
fields: 'id,username,user_id,name,profile_picture_url,account_type',
access_token: access_token
}
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
end
def make_api_request(endpoint, params, error_prefix)
response = HTTParty.get(
endpoint,
query: params,
headers: { 'Accept' => 'application/json' }
)
unless response.success?
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
raise "#{error_prefix}: #{response.body}"
end
begin
JSON.parse(response.body)
rescue JSON::ParserError => e
ChatwootExceptionTracker.new(e).capture_exception
Rails.logger.error "Invalid JSON response: #{response.body}"
raise e
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
+14 -19
View File
@@ -5,11 +5,10 @@ module SwitchLocale
def switch_locale(&) def switch_locale(&)
# priority is for locale set in query string (mostly for widget/from js sdk) # priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= params[:locale] locale ||= locale_from_params
locale ||= locale_from_custom_domain locale ||= locale_from_custom_domain
# if locale is not set in account, let's use DEFAULT_LOCALE env variable # if locale is not set in account, let's use DEFAULT_LOCALE env variable
locale ||= ENV.fetch('DEFAULT_LOCALE', nil) locale ||= locale_from_env_variable
set_locale(locale, &) set_locale(locale, &)
end end
@@ -33,30 +32,26 @@ module SwitchLocale
end end
def set_locale(locale, &) def set_locale(locale, &)
safe_locale = validate_and_get_locale(locale) # if locale is empty, use default_locale
locale ||= I18n.default_locale
# Ensure locale won't bleed into other requests # Ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests # https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
I18n.with_locale(safe_locale, &) I18n.with_locale(locale, &)
end end
def validate_and_get_locale(locale) def locale_from_params
return I18n.default_locale.to_s if locale.blank? I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
available_locales = I18n.available_locales.map(&:to_s)
locale_without_variant = locale.split('_')[0]
if available_locales.include?(locale)
locale
elsif available_locales.include?(locale_without_variant)
locale_without_variant
else
I18n.default_locale.to_s
end
end end
def locale_from_account(account) def locale_from_account(account)
return unless account return unless account
account.locale I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
end
def locale_from_env_variable
return unless ENV.fetch('DEFAULT_LOCALE', nil)
I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil
end end
end end
+2 -7
View File
@@ -7,17 +7,13 @@ class DashboardController < ActionController::Base
around_action :switch_locale around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index] before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index] before_action :render_hc_if_custom_domain, only: [:index]
before_action :ensure_html_format
layout 'vueapp' layout 'vueapp'
def index; end def index; end
private private
def ensure_html_format
head :not_acceptable unless request.format.html?
end
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get(
'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL',
@@ -36,7 +32,7 @@ class DashboardController < ActionController::Base
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE', 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV', 'DEPLOYMENT_ENV',
'INSTALLATION_PRICING_PLAN' 'CSML_EDITOR_HOST'
).merge(app_config) ).merge(app_config)
end end
@@ -65,7 +61,6 @@ class DashboardController < ActionController::Base
VAPID_PUBLIC_KEY: VapidService.public_key, VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def sign_up_user def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account?
create_account_for_user create_account_for_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
@@ -53,11 +53,9 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
).first ).first
end end
def validate_signup_email_is_business_domain? def validate_business_account?
# return true if the user is a business account, false if it is a blocked domain account # return true if the user is a business account, false if it is a gmail account
Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform auth_hash['info']['email'].exclude?('@gmail.com')
rescue CustomExceptions::Account::InvalidEmail
false
end end
def create_account_for_user def create_account_for_user
@@ -1,163 +0,0 @@
class Instagram::CallbacksController < ApplicationController
include InstagramConcern
include Instagram::IntegrationHelper
def show
# Check if Instagram redirected with an error (user canceled authorization)
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
if params[:error].present?
handle_authorization_error
return
end
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
# Process the authorization code and create inbox
def process_successful_authorization
@response = instagram_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback",
grant_type: 'authorization_code'
)
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
def handle_error(error)
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
error_info = extract_error_info(error)
redirect_to_error_page(error_info)
end
# Extract error details from the exception
def extract_error_info(error)
if error.is_a?(OAuth2::Error)
begin
# Instagram returns JSON error response which we parse to extract error details
JSON.parse(error.message)
rescue JSON::ParseError
# Fall back to a generic OAuth error if JSON parsing fails
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
end
else
# For other unexpected errors
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
end
end
# Handles the case when a user denies permissions or cancels the authorization flow
# Error parameters are documented at:
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
def handle_authorization_error
error_info = {
'error_type' => params[:error] || 'authorization_error',
'code' => 400,
'error_message' => params[:error_description] || 'Authorization was denied'
}
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
redirect_to_error_page(error_info)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_info)
redirect_to app_new_instagram_inbox_url(
account_id: account_id,
error_type: error_info['error_type'],
code: error_info['code'],
error_message: error_info['error_message']
)
end
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
ActiveRecord::Base.transaction do
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram = Channel::Instagram.create!(
access_token: @long_lived_token_response['access_token'],
instagram_id: user_details['user_id'].to_s,
account: account,
expires_at: expires_at
)
account.inboxes.create!(
account: account,
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end
def account_id
return unless params[:state]
verify_instagram_token(params[:state])
end
def oauth_code
params[:code]
end
def account
@account ||= Account.find(account_id)
end
def provider_name
'instagram'
end
end
@@ -1,73 +0,0 @@
class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
)
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
private
def oauth_client
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://api.linear.app',
token_url: '/oauth/token',
authorize_url: '/oauth/authorize'
}
)
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
def account
@account ||= Account.find(account_id)
end
def account_id
return unless params[:state]
verify_linear_token(params[:state])
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -71,7 +71,6 @@ class OauthCallbackController < ApplicationController
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,
@@ -1,9 +1,9 @@
class Platform::Api::V1::UsersController < PlatformController class Platform::Api::V1::UsersController < PlatformController
# ref: https://stackoverflow.com/a/45190318/939299 # ref: https://stackoverflow.com/a/45190318/939299
# set resource is called for other actions already in platform controller # set resource is called for other actions already in platform controller
# we want to add login and token to that chain as well # we want to add login to that chain as well
before_action(only: [:login, :token]) { set_resource } before_action(only: [:login]) { set_resource }
before_action(only: [:login, :token]) { validate_platform_app_permissible } before_action(only: [:login]) { validate_platform_app_permissible }
def show; end def show; end
@@ -18,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link } render json: { url: @resource.generate_sso_link }
end end
def token; end
def update def update
@resource.assign_attributes(user_update_params) @resource.assign_attributes(user_update_params)
@@ -1,45 +1,21 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index] before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show] before_action :set_article, only: [:show]
layout 'portal' layout 'portal'
def index def index
@articles = @portal.articles.published.includes(:category, :author) @articles = @portal.articles.published
@articles_count = @articles.count
search_articles search_articles
order_by_sort_param order_by_sort_param
limit_results @articles.page(list_params[:page]) if list_params[:page].present?
end end
def show; end def show; end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
private private
def limit_results
return if list_params[:per_page].blank?
per_page = [list_params[:per_page].to_i, 100].min
per_page = 25 if per_page < 1
@articles = @articles.page(list_params[:page]).per(per_page)
end
def search_articles def search_articles
@articles = @articles.search(list_params) if list_params.present? @articles = @articles.search(list_params) if list_params.present?
end end
@@ -54,6 +30,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug]) @article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content) @parsed_content = render_article_content(@article.content)
end end
@@ -67,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end end
def list_params def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page) params.permit(:query, :locale, :sort, :status)
end end
def permitted_params def permitted_params
@@ -1,6 +1,4 @@
class Public::Api::V1::Portals::BaseController < PublicController class Public::Api::V1::Portals::BaseController < PublicController
include SwitchLocale
before_action :show_plain_layout before_action :show_plain_layout
before_action :set_color_scheme before_action :set_color_scheme
before_action :set_global_config before_action :set_global_config
@@ -29,7 +27,14 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def switch_locale_with_portal(&) def switch_locale_with_portal(&)
@locale = validate_and_get_locale(params[:locale]) locale_without_variant = params[:locale].split('_')[0]
is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale])
is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant)
if is_locale_available
@locale = params[:locale]
elsif is_locale_variant_available
@locale = locale_without_variant
end
I18n.with_locale(@locale, &) I18n.with_locale(@locale, &)
end end
@@ -39,12 +44,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
render_404 && return if article.blank? render_404 && return if article.blank?
article_locale = if article.category.present? @locale = if article.category.present?
article.category.locale article.category.locale
else else
article.portal.default_locale article.portal.default_locale
end end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &) I18n.with_locale(@locale, &)
end end
@@ -1,72 +0,0 @@
class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
verify_account!
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to "#{redirect_uri}?error=true"
end
private
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
end
def handle_response
account.hooks.create!(
app_id: 'shopify',
access_token: parsed_body['access_token'],
status: 'enabled',
reference_id: params[:shop],
settings: {
scope: parsed_body['scope']
}
)
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def oauth_client
OAuth2::Client.new(
client_id,
client_secret,
{
site: "https://#{params[:shop]}",
authorize_url: '/admin/oauth/authorize',
token_url: '/admin/oauth/access_token'
}
)
end
def account
@account ||= Account.find(@account_id)
end
def account_id
@account_id ||= params[:state].split('_').first
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def redirect_uri
return shopify_integration_url if account
ENV.fetch('FRONTEND_URL', nil)
end
end
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
@@ -66,5 +66,3 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# rubocop:enable Rails/I18nLocaleTexts # rubocop:enable Rails/I18nLocaleTexts
end end
end end
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')
@@ -32,17 +32,16 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end end
def allowed_configs def allowed_configs
mapping = { @allowed_configs = case @config
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], when 'facebook'
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], when 'microsoft'
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], %w[AZURE_APP_ID AZURE_APP_SECRET]
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], when 'email'
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], ['MAILER_INBOUND_EMAIL_DOMAIN']
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else
} %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
end end
end end
@@ -1,6 +1,6 @@
class Twilio::CallbackController < ApplicationController class Twilio::CallbackController < ApplicationController
def create def create
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash) ::Twilio::IncomingMessageService.new(params: permitted_params).perform
head :no_content head :no_content
end end
@@ -24,10 +24,9 @@ class Twilio::CallbackController < ApplicationController
:Body, :Body,
:ToCountry, :ToCountry,
:FromState, :FromState,
*Array.new(10) { |i| :"MediaUrl#{i}" }, :MediaUrl0,
*Array.new(10) { |i| :"MediaContentType#{i}" }, :MediaContentType0,
:MessagingServiceSid, :MessagingServiceSid
:NumMedia
) )
end end
end end

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