Merge branch 'develop' into chore/conversation-participants

This commit is contained in:
Sojan Jose 2022-09-28 13:23:06 -07:00 committed by GitHub
commit ac1e698a1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2331 changed files with 113825 additions and 17341 deletions

View file

@ -7,16 +7,19 @@ defaults: &defaults
working_directory: ~/build working_directory: ~/build
docker: docker:
# specify the version you desire here # specify the version you desire here
- image: cimg/ruby:3.0.2-browsers - image: cimg/ruby:3.0.4-browsers
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/ # documented at https://circleci.com/docs/2.0/circleci-images/
- image: circleci/postgres:alpine - image: cimg/postgres:14.1
- image: circleci/redis:alpine - image: cimg/redis:6.2.6
environment: environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
jobs: jobs:
build: build:
<<: *defaults <<: *defaults
@ -40,14 +43,13 @@ jobs:
- restore_cache: - restore_cache:
keys: keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} - chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
- chatwoot-bundle
- run: bundle install --frozen --path ~/.bundle - run: bundle install --frozen --path ~/.bundle
- save_cache: - save_cache:
paths: paths:
- ~/.bundle - ~/.bundle
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-{{ checksum "Gemfile.lock" }} key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
# Only necessary if app uses webpacker or yarn in some other way # Only necessary if app uses webpacker or yarn in some other way
@ -89,6 +91,7 @@ jobs:
fi fi
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/5.3.0/openapi-generator-cli-5.3.0.jar > ~/tmp/openapi-generator-cli-5.3.0.jar
java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-5.3.0.jar validate -i swagger/swagger.json
# Database setup # Database setup
- run: yarn install --check-files - run: yarn install --check-files
- run: bundle exec rake db:create - run: bundle exec rake db:create
@ -101,6 +104,10 @@ jobs:
- run: - run:
name: Rubocop name: Rubocop
command: bundle exec rubocop command: bundle exec rubocop
# - run:
# name: Brakeman
# command: bundle exec brakeman
- run: - run:
name: eslint name: eslint
@ -110,34 +117,77 @@ jobs:
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 mkdir -p ~/tmp/test-results/rspec
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json mkdir -p ~/tmp/test-artifacts
- persist_to_workspace: mkdir -p coverage
root: ~/tmp ~/tmp/cc-test-reporter before-build
paths: TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
- codeclimate.backend.json bundle exec rspec --profile 10 \
--out test-results/rspec/rspec.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- run: - run:
name: Run frontend tests name: Run frontend tests
command: | command: |
yarn test:coverage mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter format-coverage -t lcov -o ~/tmp/codeclimate.frontend.json buildreports/lcov.info ~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out test-results/frontend_specs/rspec.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
- persist_to_workspace: - persist_to_workspace:
root: ~/tmp root: coverage
paths: paths:
- codeclimate.frontend.json - codeclimate.*.json
# collect reports # collect reports
- store_test_results: - store_test_results:
path: ~/tmp/test-results path: ~/tmp/test-results
- store_artifacts: - store_artifacts:
path: ~/tmp/test-results path: ~/tmp/test-artifacts
destination: test-results
- store_artifacts: - store_artifacts:
path: log path: log
upload-coverage:
working_directory: ~/build
docker:
# specify the version you desire here
- image: circleci/ruby:3.0.2-node-browsers
environment:
- CC_TEST_REPORTER_ID: caf26a895e937974a90860cfadfded20891cfd1373a5aaafb3f67406ab9d433f
steps:
- attach_workspace:
at: ~/build
- run:
name: Download cc-test-reporter
command: |
mkdir -p ~/tmp
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
chmod +x ~/tmp/cc-test-reporter
- persist_to_workspace:
root: ~/tmp
paths:
- cc-test-reporter
- run: - run:
name: Upload coverage results to Code Climate name: Upload coverage results to Code Climate
command: | command: |
~/tmp/cc-test-reporter sum-coverage ~/tmp/codeclimate.*.json -p 2 -o ~/tmp/codeclimate.total.json ~/tmp/cc-test-reporter sum-coverage --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input -
~/tmp/cc-test-reporter upload-coverage -i ~/tmp/codeclimate.total.json
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build

View file

@ -50,3 +50,7 @@ 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/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'

View file

@ -5,4 +5,4 @@ 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 . /workspace COPY . /workspace
RUN yarn && gem install bundler && bundle install RUN yarn && gem install bundler && bundle install

View file

@ -1,6 +1,6 @@
# pre-build stage
ARG VARIANT=3 ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT} FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000 ARG USER_UID=1000
@ -11,23 +11,36 @@ 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
# [Option] Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \ && apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \ libssl-dev \
zlib1g-dev \
gnupg2 \
tar \ tar \
tzdata \ tzdata \
postgresql-client \ postgresql-client \
libpq-dev \
yarn \ yarn \
git \ git \
imagemagick \ imagemagick \
tmux \ tmux \
zsh zsh \
git-flow \
npm
# Install rbenv and ruby
ARG RUBY_VERSION="3.0.4"
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Install overmind # Install overmind
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 \
@ -35,11 +48,25 @@ RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmi
&& sudo mv overmind /usr/local/bin \ && sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind && chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app # Do the set up required for chatwoot app
WORKDIR /workspace WORKDIR /workspace
COPY . /workspace COPY . /workspace
RUN yarn
# set up ruby
COPY Gemfile Gemfile.lock ./ COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install RUN gem install bundler && bundle install
# set up node js
RUN npm install npm@latest -g && \
npm install n -g && \
n latest
RUN npm install --global yarn
RUN yarn

View file

@ -23,17 +23,18 @@
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025], "forwardPorts": [8025, 3000, 3035],
//your application may need to listen on all interfaces (0.0.0.0) not just localhost for it to be available externally. Defaults to []
"appPort": [3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn", "postCreateCommand": ".devcontainer/scripts/setup.sh && bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": { "portsAttributes": {
"3000": { "3000": {
"label": "Rails Server" "label": "Rails Server"
}, },
"3035": {
"label": "Webpack Dev Server"
},
"8025": { "8025": {
"label": "Mailhog UI" "label": "Mailhog UI"
} }
}, }
} }

View file

@ -6,3 +6,8 @@ sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.de
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable # uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME

View file

@ -3,6 +3,8 @@ SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app # Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000 FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000
# If the variable is set, all non-authenticated pages would fallback to the default locale. # If the variable is set, all non-authenticated pages would fallback to the default locale.
# Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en # Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en
@ -32,7 +34,15 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command # You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME= REDIS_SENTINEL_MASTER_NAME=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# Postgres Database config variables # Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
# POSTGRES_DATABASE=
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
@ -43,7 +53,6 @@ RAILS_MAX_THREADS=5
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>` # could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>" MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
#SMTP domain key is set up for HELO checking #SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com SMTP_DOMAIN=chatwoot.com
# the default value is set "mailhog" and is used by docker-compose for development environments, # the default value is set "mailhog" and is used by docker-compose for development environments,
@ -88,7 +97,6 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_REGION= AWS_REGION=
# Log settings # Log settings
# Disable if you want to write logs to a file # Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true RAILS_LOG_TO_STDOUT=true
@ -125,7 +133,6 @@ ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section) # https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8 ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
### Smart App Banner ### Smart App Banner
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html # https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
# You can find your app-id in https://itunesconnect.apple.com # You can find your app-id in https://itunesconnect.apple.com
@ -142,8 +149,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## Bot Customizations ## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true USE_INBOX_AVATAR_FOR_BOT=true
### APM and Error Monitoring configurations ### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
# ELASTIC_APM_SERVER_URL=
# ELASTIC_APM_SECRET_TOKEN=
## Sentry ## Sentry
# SENTRY_DSN= # SENTRY_DSN=
@ -156,13 +167,14 @@ USE_INBOX_AVATAR_FOR_BOT=true
## NewRelic ## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/ # https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# NEW_RELIC_LICENSE_KEY= # NEW_RELIC_LICENSE_KEY=
# Set this to true to allow newrelic apm to send logs.
# This is turned off by default.
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
## Datadog ## Datadog
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL= # DD_TRACE_AGENT_URL=
## IP look up configuration ## IP look up configuration
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md ## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
## works only on accounts with ip look up feature enabled ## works only on accounts with ip look up feature enabled
@ -174,7 +186,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
## To prevent and throttle abusive requests ## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true # ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server ## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints ## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false # CW_API_ONLY_SERVER=false
@ -188,3 +199,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
# If you want to use official mobile app, # If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server # the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true ENABLE_PUSH_RELAY_SERVER=true
# Stripe API key
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Set to true if you want to upload files to cloud storage using the signed url
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=

View file

@ -1,5 +1,10 @@
module.exports = { module.exports = {
extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'], extends: [
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
],
parserOptions: { parserOptions: {
parser: 'babel-eslint', parser: 'babel-eslint',
ecmaVersion: 2020, ecmaVersion: 2020,
@ -19,18 +24,32 @@ module.exports = {
'jsx-a11y/label-has-for': 'off', 'jsx-a11y/label-has-for': 'off',
'jsx-a11y/anchor-is-valid': 'off', 'jsx-a11y/anchor-is-valid': 'off',
'import/no-unresolved': 'off', 'import/no-unresolved': 'off',
'vue/max-attributes-per-line': ['error', { 'vue/max-attributes-per-line': [
'singleline': 20, 'error',
'multiline': { {
'max': 1, singleline: 20,
'allowFirstLine': false multiline: {
max: 1,
allowFirstLine: false,
},
}, },
}], ],
'vue/html-self-closing': 'off', 'vue/html-self-closing': [
"vue/no-v-html": 'off', 'error',
{
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/no-v-html': 'off',
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'] 'import/extensions': ['off'],
'no-console': 'error',
}, },
settings: { settings: {
'import/resolver': { 'import/resolver': {
@ -41,12 +60,10 @@ module.exports = {
}, },
env: { env: {
browser: true, browser: true,
node: true,
jest: true, jest: true,
jasmine: true node: true,
}, },
globals: { globals: {
__WEBPACK_ENV__: true,
bus: true, bus: true,
}, },
}; };

36
.github/workflows/lock.yml vendored Normal file
View file

@ -0,0 +1,36 @@
# We often have cases where users would comment over stale closed Github Issues.
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
# This action locks the closed threads once it is inactive for over a month.
name: 'Lock Threads'
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: lock
jobs:
action:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v3
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.

46
.github/workflows/nightly_installer.yml vendored Normal file
View file

@ -0,0 +1,46 @@
# #
# #
# # Linux nightly installer action
# # This action will try to install and setup
# # chatwoot on an Ubuntu 20.04 machine using
# # the linux installer script.
# #
# # This is set to run daily at midnight.
# #
name: Run Linux nightly installer
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
nightly:
runs-on: ubuntu-20.04
steps:
- name: get installer
run: |
wget https://get.chatwoot.app/linux/install.sh
chmod +x install.sh
#fix for postgtres not starting automatically in gh action env
sed -i '/function configure_db() {/a sudo service postgresql start' install.sh
- name: create input file
run: |
echo "no" > input
echo "yes" >> input
- name: Run the installer
run: |
sudo ./install.sh --install < input
# disabling http verify for now as http
# access to port 3000 fails in gh action env
# - name: Verify
# if: always()
# run: |
# sudo netstat -ntlp | grep 3000
# sudo systemctl restart chatwoot.target
# curl http://localhost:3000/api

View file

@ -0,0 +1,62 @@
# #
# # This action will publish Chatwoot CE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ env.DOCKER_TAG }}

73
.github/workflows/run_foss_spec.yml vendored Normal file
View file

@ -0,0 +1,73 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.4 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation

View file

@ -16,6 +16,8 @@ Metrics/ClassLength:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb' - 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@ -181,3 +183,5 @@ AllCops:
- db/migrate/20200503151130_add_account_feature_flag.rb - db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb - db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb - db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb

View file

@ -1 +1 @@
3.0.2 3.0.4

View file

@ -4,6 +4,7 @@ import Vuex from 'vuex';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate'; import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect'; import Multiselect from 'vue-multiselect';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components'; import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n'; import i18n from '../app/javascript/dashboard/i18n';
@ -15,6 +16,7 @@ Vue.use(Vuelidate);
Vue.use(WootUiKit); Vue.use(WootUiKit);
Vue.use(Vuex); Vue.use(Vuex);
Vue.component('multiselect', Multiselect); Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({}); const store = new Vuex.Store({});
const i18nConfig = new VueI18n({ const i18nConfig = new VueI18n({

43
Gemfile
View file

@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.0.2' ruby '3.0.4'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rails' gem 'rails', '~>6.1'
# 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
@ -42,7 +42,7 @@ gem 'down', '~> 5.0'
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false gem 'azure-storage-blob', require: false
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'image_processing' gem 'image_processing', '~> 1.12.2'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
@ -78,7 +78,7 @@ gem 'wisper', '2.0.0'
# TODO: bump up gem to 2.0 # TODO: bump up gem to 2.0
gem 'facebook-messenger' gem 'facebook-messenger'
gem 'line-bot-api' gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.32.0' gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty' # gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty' gem 'twitty'
@ -89,22 +89,19 @@ gem 'slack-ruby-client'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow' gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
##-- apm and error monitoring ---# ##-- apm and error monitoring ---#
gem 'ddtrace' gem 'ddtrace'
gem 'elastic-apm'
gem 'newrelic_rpm' gem 'newrelic_rpm'
gem 'scout_apm' gem 'scout_apm'
gem 'sentry-rails' gem 'sentry-rails', '~> 5.3'
gem 'sentry-ruby' gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq' gem 'sentry-sidekiq', '~> 5.3'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 6.4.0' gem 'sidekiq', '~> 6.4.0'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron' gem 'sidekiq-cron', '~> 1.3'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@ -125,6 +122,19 @@ gem 'procore-sift'
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' gem 'html2text'
# to calculate working hours
gem 'working_hours'
# full text search for articles
gem 'pg_search'
# Subscriptions, Billing
gem 'stripe'
## - helper gems --##
## to populate db with sample data
gem 'faker'
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'
@ -153,17 +163,14 @@ group :test do
end end
group :development, :test do group :development, :test do
# TODO: is this needed ?
# errors thrown by devise password gem
gem 'flay'
gem 'rspec'
# for error thrown by devise password gem
gem 'active_record_query_trace' gem 'active_record_query_trace'
##--- gems for debugging and error reporting ---##
# static analysis
gem 'brakeman'
gem 'bundle-audit', require: false gem 'bundle-audit', require: false
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'climate_control' gem 'climate_control'
gem 'factory_bot_rails' gem 'factory_bot_rails'
gem 'faker'
gem 'listen' gem 'listen'
gem 'mock_redis' gem 'mock_redis'
gem 'pry-rails' gem 'pry-rails'

View file

@ -1,6 +1,6 @@
GIT GIT
remote: https://github.com/chatwoot/devise-secure_password remote: https://github.com/chatwoot/devise-secure_password
revision: de11e8765654b8242d42101ee9c8ffc8126f7975 revision: d777b04f12652d576b1272b8f39857e3e0b3fc26
specs: specs:
devise-secure_password (2.0.1) devise-secure_password (2.0.1)
devise (>= 4.0.0, < 5.0.0) devise (>= 4.0.0, < 5.0.0)
@ -9,63 +9,63 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.4.7) actioncable (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.7) actionmailbox (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
activejob (= 6.1.4.7) activejob (= 6.1.6.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.6.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.4.7) actionmailer (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
actionview (= 6.1.4.7) actionview (= 6.1.6.1)
activejob (= 6.1.4.7) activejob (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.4.7) actionpack (6.1.6.1)
actionview (= 6.1.4.7) actionview (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.7) actiontext (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.6.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.4.7) actionview (6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.4.7) activejob (6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.4.7) activemodel (6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
activerecord (6.1.4.7) activerecord (6.1.6.1)
activemodel (= 6.1.4.7) activemodel (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
activerecord-import (1.3.0) activerecord-import (1.4.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (6.1.4.7) activestorage (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
activejob (= 6.1.4.7) activejob (= 6.1.6.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
marcel (~> 1.0.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.4.7) activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -91,20 +91,20 @@ GEM
ast (2.4.2) ast (2.4.2)
attr_extras (6.2.5) attr_extras (6.2.5)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.556.0) aws-partitions (1.605.0)
aws-sdk-core (3.126.2) aws-sdk-core (3.131.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.525.0) aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.54.0) aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.126.0) aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.112.0) aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.126.0) aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0) aws-sigv4 (1.5.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0) azure-storage-common (~> 2.0)
@ -117,31 +117,31 @@ GEM
barnes (0.0.9) barnes (0.0.9)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
bcrypt (3.1.16) bcrypt (3.1.18)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.10.3) bootsnap (1.12.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.2.1) brakeman (5.2.3)
browser (5.3.1) browser (5.3.1)
builder (3.2.4) builder (3.2.4)
bullet (7.0.1) bullet (7.0.2)
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)
bundler-audit bundler-audit
bundler-audit (0.9.0.1) bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3) bundler (>= 1.2.0, < 3)
thor (~> 1.0) thor (~> 1.0)
byebug (11.1.3) byebug (11.1.3)
climate_control (1.0.1) climate_control (1.1.1)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.4) commonmarker (0.23.6)
concurrent-ruby (1.1.9) concurrent-ruby (1.1.10)
connection_pool (2.2.5) connection_pool (2.2.5)
crack (0.4.5) crack (0.4.5)
rexml rexml
crass (1.0.6) crass (1.0.6)
cypress-on-rails (1.12.1) cypress-on-rails (1.13.1)
rack rack
database_cleaner (2.0.1) database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0) database_cleaner-active_record (~> 2.0.0)
@ -151,10 +151,12 @@ GEM
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7) datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1) momentjs-rails (>= 2.8.1)
ddtrace (0.54.2) ddtrace (1.2.0)
debase-ruby_core_source (<= 0.10.14) debase-ruby_core_source (= 0.10.16)
libddprof (~> 0.6.0.1.0)
libddwaf (~> 1.3.0.2.0)
msgpack msgpack
debase-ruby_core_source (0.10.14) debase-ruby_core_source (0.10.16)
declarative (0.0.20) declarative (0.0.20)
devise (4.8.1) devise (4.8.1)
bcrypt (~> 3.0) bcrypt (~> 3.0)
@ -176,54 +178,78 @@ GEM
dotenv-rails (2.7.6) dotenv-rails (2.7.6)
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
down (5.3.0) down (5.3.1)
addressable (~> 2.8) addressable (~> 2.8)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
elastic-apm (4.5.1)
concurrent-ruby (~> 1.0)
http (>= 3.0)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.10.0) erubi (1.10.0)
erubis (2.7.0) et-orbi (1.2.7)
et-orbi (1.2.6)
tzinfo tzinfo
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)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (6.2.0) factory_bot (6.2.1)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.2.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (2.19.0) faker (2.21.0)
i18n (>= 1.6, < 2) i18n (>= 1.8.11, < 2)
faraday (1.0.1) faraday (1.10.0)
multipart-post (>= 1.2, < 3) faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0) faraday_middleware (1.2.0)
faraday (~> 1.0) faraday (~> 1.0)
fcm (1.0.5) fcm (1.0.8)
faraday (~> 1) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5) ffi (1.15.5)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
flag_shih_tzu (0.3.23) flag_shih_tzu (0.3.23)
flay (2.12.1)
erubis (~> 2.7.0)
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
foreman (0.87.2) foreman (0.87.2)
fugit (1.5.2) fugit (1.5.3)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4) raabro (~> 1.4)
gapic-common (0.3.4) gapic-common (0.10.0)
google-protobuf (~> 3.12, >= 3.12.2) faraday (>= 1.9, < 3.a)
googleapis-common-protos (>= 1.3.9, < 2.0) faraday-retry (>= 1.0, < 3.a)
googleapis-common-protos-types (>= 1.0.4, < 2.0) google-protobuf (~> 3.14)
googleauth (~> 0.9) googleapis-common-protos (>= 1.3.12, < 2.a)
grpc (~> 1.25) googleapis-common-protos-types (>= 1.3.1, < 2.a)
geocoder (1.7.3) googleauth (~> 1.0)
grpc (~> 1.36)
geocoder (1.8.0)
gli (2.21.0) gli (2.21.0)
globalid (1.0.0) globalid (1.0.0)
activesupport (>= 5.0) activesupport (>= 5.0)
google-apis-core (0.4.2) google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@ -232,23 +258,27 @@ GEM
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick webrick
google-apis-iamcredentials_v1 (0.10.0) google-apis-iamcredentials_v1 (0.13.0)
google-apis-core (>= 0.4, < 2.a) google-apis-core (>= 0.7, < 2.a)
google-apis-storage_v1 (0.11.0) google-apis-storage_v1 (0.18.0)
google-apis-core (>= 0.4, < 2.a) google-apis-core (>= 0.7, < 2.a)
google-cloud-core (1.6.0) google-cloud-core (1.6.0)
google-cloud-env (~> 1.0) google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-dialogflow (1.2.0) google-cloud-dialogflow (1.5.0)
google-cloud-core (~> 1.5) google-cloud-core (~> 1.6)
google-cloud-dialogflow-v2 (~> 0.1) google-cloud-dialogflow-v2 (>= 0.15, < 2.a)
google-cloud-dialogflow-v2 (0.6.4) google-cloud-dialogflow-v2 (0.17.0)
gapic-common (~> 0.3) gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0) google-cloud-location (>= 0.0, < 2.a)
faraday (>= 0.17.3, < 2.0) google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.2.0) google-cloud-errors (1.2.0)
google-cloud-storage (1.36.1) google-cloud-location (0.2.0)
gapic-common (>= 0.10, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.37.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-iamcredentials_v1 (~> 0.1)
@ -256,32 +286,32 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-protobuf (3.19.4) google-protobuf (3.21.2)
google-protobuf (3.19.4-x86_64-darwin) google-protobuf (3.21.2-x86_64-darwin)
google-protobuf (3.19.4-x86_64-linux) google-protobuf (3.21.2-x86_64-linux)
googleapis-common-protos (1.3.12) googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2) googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27) grpc (~> 1.27)
googleapis-common-protos-types (1.3.0) googleapis-common-protos-types (1.3.2)
google-protobuf (~> 3.14) google-protobuf (~> 3.14)
googleauth (0.17.1) googleauth (1.2.0)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (~> 0.15) signet (>= 0.16, < 2.a)
groupdate (6.0.1) groupdate (6.1.0)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.43.1) grpc (1.47.0)
google-protobuf (~> 3.18) google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-universal-darwin) grpc (1.47.0-x86_64-darwin)
google-protobuf (~> 3.18) google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.43.1-x86_64-linux) grpc (1.47.0-x86_64-linux)
google-protobuf (~> 3.18) google-protobuf (~> 3.19)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (0.2.25) hairtrigger (0.2.25)
@ -294,14 +324,20 @@ GEM
hkdf (0.3.0) hkdf (0.3.0)
html2text (0.2.1) html2text (0.2.1)
nokogiri (~> 1.6) nokogiri (~> 1.6)
http (5.1.0)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-accept (1.7.0) http-accept (1.7.0)
http-cookie (1.0.4) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.20.0) httparty (0.20.0)
mime-types (~> 3.0) mime-types (~> 3.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.10.0) i18n (1.11.0)
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)
@ -309,20 +345,20 @@ GEM
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
jmespath (1.6.0) jmespath (1.6.1)
jquery-rails (4.4.0) jquery-rails (4.5.0)
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.6.1) json (2.6.2)
json_refs (0.1.7) json_refs (0.1.7)
hana hana
json_schemer (0.2.19) json_schemer (0.2.21)
ecma-re-validator (~> 0.3) ecma-re-validator (~> 0.3)
hana (~> 1.3) hana (~> 1.3)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
uri_template (~> 0.7) uri_template (~> 0.7)
jwt (2.3.0) jwt (2.4.1)
kaminari (1.2.2) kaminari (1.2.2)
activesupport (>= 4.1.0) activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2) kaminari-actionview (= 1.2.2)
@ -335,21 +371,34 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
koala (3.1.0) koala (3.2.0)
addressable addressable
faraday (< 2) faraday (< 2)
json (>= 1.8) json (>= 1.8)
rexml rexml
launchy (2.5.0) launchy (2.5.0)
addressable (~> 2.7) addressable (~> 2.7)
letter_opener (1.7.0) letter_opener (1.8.1)
launchy (~> 2.2) launchy (>= 2.2, < 3)
line-bot-api (1.23.0) libddprof (0.6.0.1.0)
liquid (5.1.0) libddprof (0.6.0.1.0-x86_64-linux)
libddwaf (1.3.0.2.0)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.3.0.2.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.25.0)
liquid (5.3.0)
listen (3.7.1) listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10) rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.14.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
loofah (2.18.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
@ -364,37 +413,39 @@ GEM
mini_magick (4.11.0) mini_magick (4.11.0)
mini_mime (1.1.2) mini_mime (1.1.2)
mini_portile2 (2.8.0) mini_portile2 (2.8.0)
minitest (5.15.0) minitest (5.16.2)
mock_redis (0.30.0) mock_redis (0.32.0)
ruby2_keywords ruby2_keywords
momentjs-rails (2.29.1.1) momentjs-rails (2.29.1.1)
railties (>= 3.1) railties (>= 3.1)
msgpack (1.4.5) msgpack (1.5.3)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.1.1) multipart-post (2.2.3)
net-http-persistent (4.0.1) net-http-persistent (4.0.1)
connection_pool (~> 2.2) connection_pool (~> 2.2)
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (8.4.0) newrelic_rpm (8.9.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.3) nokogiri (1.13.7)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-arm64-darwin) nokogiri (1.13.7-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-darwin) nokogiri (1.13.7-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-linux) nokogiri (1.13.7-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.8) oauth (0.5.10)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
parallel (1.21.0) parallel (1.22.1)
parser (3.1.1.0) parser (3.1.2.0)
ast (~> 2.4.1) ast (~> 2.4.1)
path_expander (1.1.0) pg (1.4.1)
pg (1.3.2) pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
procore-sift (0.16.0) procore-sift (0.16.0)
rails (> 4.2.0) rails (> 4.2.0)
pry (0.14.1) pry (0.14.1)
@ -402,59 +453,59 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.7)
puma (5.6.2) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.6.0) racc (1.6.0)
rack (2.2.3) rack (2.2.4)
rack-attack (6.6.0) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-proxy (0.7.2) rack-proxy (0.7.2)
rack rack
rack-test (1.1.0) rack-test (2.0.2)
rack (>= 1.0, < 3) rack (>= 1.3)
rack-timeout (0.6.0) rack-timeout (0.6.3)
rails (6.1.4.7) rails (6.1.6.1)
actioncable (= 6.1.4.7) actioncable (= 6.1.6.1)
actionmailbox (= 6.1.4.7) actionmailbox (= 6.1.6.1)
actionmailer (= 6.1.4.7) actionmailer (= 6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
actiontext (= 6.1.4.7) actiontext (= 6.1.6.1)
actionview (= 6.1.4.7) actionview (= 6.1.6.1)
activejob (= 6.1.4.7) activejob (= 6.1.6.1)
activemodel (= 6.1.4.7) activemodel (= 6.1.6.1)
activerecord (= 6.1.4.7) activerecord (= 6.1.6.1)
activestorage (= 6.1.4.7) activestorage (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.4.7) railties (= 6.1.6.1)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2) rails-html-sanitizer (1.4.3)
loofah (~> 2.3) loofah (~> 2.3)
railties (6.1.4.7) railties (6.1.6.1)
actionpack (= 6.1.4.7) actionpack (= 6.1.6.1)
activesupport (= 6.1.4.7) activesupport (= 6.1.6.1)
method_source method_source
rake (>= 0.13) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
rb-fsevent (0.11.1) rb-fsevent (0.11.1)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
redis (4.6.0) redis (4.7.1)
redis-namespace (1.8.1) redis-namespace (1.8.2)
redis (>= 3.0.4) redis (>= 3.0.4)
regexp_parser (2.2.1) regexp_parser (2.5.0)
representable (3.1.1) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
@ -468,16 +519,12 @@ GEM
netrc (~> 0.8) netrc (~> 0.8)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.5) rexml (3.2.5)
rspec (3.11.0)
rspec-core (~> 3.11.0)
rspec-expectations (~> 3.11.0)
rspec-mocks (~> 3.11.0)
rspec-core (3.11.0) rspec-core (3.11.0)
rspec-support (~> 3.11.0) rspec-support (~> 3.11.0)
rspec-expectations (3.11.0) rspec-expectations (3.11.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0) rspec-support (~> 3.11.0)
rspec-mocks (3.11.0) rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.11.0) rspec-support (~> 3.11.0)
rspec-rails (5.0.3) rspec-rails (5.0.3)
@ -489,26 +536,27 @@ GEM
rspec-mocks (~> 3.10) rspec-mocks (~> 3.10)
rspec-support (~> 3.10) rspec-support (~> 3.10)
rspec-support (3.11.0) rspec-support (3.11.0)
rubocop (1.25.1) rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.1.0.0) parser (>= 3.1.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.15.1, < 2.0) rubocop-ast (>= 1.18.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.16.0) rubocop-ast (1.19.1)
parser (>= 3.1.1.0) parser (>= 3.1.1.0)
rubocop-performance (1.13.2) rubocop-performance (1.14.2)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.13.2) rubocop-rails (2.15.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.8.0) rubocop-rspec (2.12.1)
rubocop (~> 1.19) rubocop (~> 1.31)
ruby-progressbar (1.11.0) ruby-progressbar (1.11.0)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
@ -516,7 +564,7 @@ GEM
ruby2ruby (2.4.4) ruby2ruby (2.4.4)
ruby_parser (~> 3.1) ruby_parser (~> 3.1)
sexp_processor (~> 4.6) sexp_processor (~> 4.6)
ruby_parser (3.18.1) ruby_parser (3.19.1)
sexp_processor (~> 4.16) sexp_processor (~> 4.16)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
@ -526,37 +574,37 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
scout_apm (5.1.1) scout_apm (5.2.0)
parser parser
seed_dump (3.3.1) seed_dump (3.3.1)
activerecord (>= 4) activerecord (>= 4)
activesupport (>= 4) activesupport (>= 4)
selectize-rails (0.12.6) selectize-rails (0.12.6)
semantic_range (3.0.0) semantic_range (3.0.0)
sentry-rails (5.1.0) sentry-rails (5.3.1)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby-core (~> 5.1.0) sentry-ruby-core (~> 5.3.1)
sentry-ruby (5.1.0) sentry-ruby (5.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-ruby-core (= 5.1.0) sentry-ruby-core (= 5.3.1)
sentry-ruby-core (5.1.0) sentry-ruby-core (5.3.1)
concurrent-ruby concurrent-ruby
sentry-sidekiq (5.1.0) sentry-sidekiq (5.3.1)
sentry-ruby-core (~> 5.1.0) sentry-ruby-core (~> 5.3.1)
sidekiq (>= 3.0) sidekiq (>= 3.0)
sexp_processor (4.16.0) sexp_processor (4.16.1)
shoulda-matchers (5.1.0) shoulda-matchers (5.1.0)
activesupport (>= 5.2.0) activesupport (>= 5.2.0)
sidekiq (6.4.1) sidekiq (6.4.2)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
sidekiq-cron (1.2.0) sidekiq-cron (1.6.0)
fugit (~> 1.1) fugit (~> 1)
sidekiq (>= 4.2.1) sidekiq (>= 4.2.1)
signet (0.16.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simplecov (0.17.1) simplecov (0.17.1)
@ -574,7 +622,7 @@ GEM
spring-watcher-listen (2.0.1) spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0) spring (>= 1.2, < 3.0)
sprockets (4.0.3) sprockets (4.1.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.4.2) sprockets-rails (3.4.2)
@ -583,31 +631,32 @@ GEM
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
squasher (0.6.2) squasher (0.6.2)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
telephone_number (1.4.13) stripe (6.5.0)
telephone_number (1.4.16)
thor (1.2.1) thor (1.2.1)
tilt (2.0.10) tilt (2.0.10)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.32.0) twilio-ruby (5.68.0)
faraday (~> 1.0.0) faraday (>= 0.9, < 3.0)
jwt (>= 1.5, <= 2.5) jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0) nokogiri (>= 1.6, < 2.0)
twitty (0.1.4) twitty (0.1.4)
oauth oauth
tzinfo (2.0.4) tzinfo (2.0.4)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.5) tzinfo-data (1.2022.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
uglifier (4.2.0) uglifier (4.2.0)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8) unf_ext (0.0.8.2)
unicode-display_width (2.1.0) unicode-display_width (2.2.0)
uniform_notifier (1.14.2) uniform_notifier (1.16.0)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (4.0.3) valid_email2 (4.0.3)
activemodel (>= 3.2) activemodel (>= 3.2)
@ -636,7 +685,10 @@ GEM
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)
zeitwerk (2.5.4) working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.0)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@ -672,13 +724,13 @@ DEPENDENCIES
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
down (~> 5.0) down (~> 5.0)
elastic-apm
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails factory_bot_rails
faker faker
fcm fcm
flag_shih_tzu flag_shih_tzu
flay
foreman foreman
geocoder geocoder
google-cloud-dialogflow google-cloud-dialogflow
@ -688,7 +740,7 @@ DEPENDENCIES
hairtrigger hairtrigger
hashie hashie
html2text html2text
image_processing image_processing (~> 1.12.2)
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
@ -703,6 +755,7 @@ DEPENDENCIES
mock_redis mock_redis
newrelic_rpm newrelic_rpm
pg pg
pg_search
procore-sift procore-sift
pry-rails pry-rails
puma puma
@ -710,12 +763,11 @@ DEPENDENCIES
rack-attack rack-attack
rack-cors rack-cors
rack-timeout rack-timeout
rails rails (~> 6.1)
redis redis
redis-namespace redis-namespace
responders responders
rest-client rest-client
rspec
rspec-rails (~> 5.0.0) rspec-rails (~> 5.0.0)
rubocop rubocop
rubocop-performance rubocop-performance
@ -723,20 +775,21 @@ DEPENDENCIES
rubocop-rspec rubocop-rspec
scout_apm scout_apm
seed_dump seed_dump
sentry-rails sentry-rails (~> 5.3)
sentry-ruby sentry-ruby (~> 5.3)
sentry-sidekiq sentry-sidekiq (~> 5.3)
shoulda-matchers shoulda-matchers
sidekiq (~> 6.4.0) sidekiq (~> 6.4.0)
sidekiq-cron sidekiq-cron (~> 1.3)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client slack-ruby-client
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher
stripe
telephone_number telephone_number
time_diff time_diff
twilio-ruby (~> 5.32.0) twilio-ruby (~> 5.66)
twitty twitty
tzinfo-data tzinfo-data
uglifier uglifier
@ -746,9 +799,10 @@ DEPENDENCIES
webpacker (~> 5.x) webpacker (~> 5.x)
webpush webpush
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours
RUBY VERSION RUBY VERSION
ruby 3.0.2p107 ruby 3.0.4p208
BUNDLED WITH BUNDLED WITH
2.3.8 2.3.17

View file

@ -16,7 +16,7 @@
___ ___
<p align="center"> <p align="center">
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/80f9e1a7c72d186289ad/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>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@ -26,6 +26,7 @@ ___
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a> <a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a> <a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a> <a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/> <img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/>

View file

@ -1,30 +1,55 @@
# Security Policy Chatwoot is looking forward to working with security researchers worldwide to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us.
Chatwoot is looking forward to working with security researchers across the world to keep Chatwoot and our users safe. If you have found an issue in our systems/applications, please reach out to us.
## Reporting a Vulnerability ## Reporting a Vulnerability
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). 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.
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, feel free to reach out to 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 Huntr as quickly as possible.
> Note: Please use the email for questions related to the process. Disclosures should be done via [huntr.dev](https://huntr.dev/)
## Supported versions
| Version | Supported |
| ------- | -------------- |
| latest | ️✅ |
| <latest | |
## Out of scope ## Vulnerabilities we care about 🫣
> Note: Please do not perform testing against Chatwoot production services. Use a `self-hosted instance` to perform tests.
- Remote command execution
- SQL Injection
- Authentication bypass
- Privilege Escalation
- Cross-site scripting (XSS)
- Performing limited admin actions without authorization
- CSRF
Please do not perform testing against Chatwoot production services. Use a self hosted instance to perform tests. You can learn more about our triaging process [here](https://www.chatwoot.com/docs/contributing-guide/security-reports).
We consider the following to be out of scope, though there may be exceptions. ## Non-Qualifying Vulnerabilities
We consider the following out of scope, though there may be exceptions.
- Missing HTTP security headers - Missing HTTP security headers
- Self XSS - Incomplete/Missing SPF/DKIM
- HTTP Host Header XSS without working proof-of-concept - Reports from automated tools or scanners
- Theoretical attacks without proof of exploitability
- Social engineering
- Reflected file download
- Physical attacks
- Weak SSL/TLS/SSH algorithms or protocols
- Attacks involving physical access to a user's device or a device or network that's already seriously compromised (e.g., man-in-the-middle).
- The user attacks themselves
- Incomplete/Missing SPF/DKIM - Incomplete/Missing SPF/DKIM
- Denial of Service attacks - Denial of Service attacks
- Brute force attacks
- DNSSEC - DNSSEC
- Social Engineering attacks
If you are not sure about the scope, please create a report. If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks ## Thanks

1
VERSION_CW Normal file
View file

@ -0,0 +1 @@
2.2.0

1
VERSION_CWCTL Normal file
View file

@ -0,0 +1 @@
2.1.0

View file

@ -32,6 +32,10 @@
"INSTALLATION_ENV": { "INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.", "description": "Installation method used for Chatwoot.",
"value": "heroku" "value": "heroku"
},
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
} }
}, },
"formation": { "formation": {
@ -44,6 +48,7 @@
"size": "FREE" "size": "FREE"
} }
}, },
"stack": "heroku-20",
"image": "heroku/ruby", "image": "heroku/ruby",
"addons": [ "heroku-redis", "heroku-postgresql"], "addons": [ "heroku-redis", "heroku-postgresql"],
"buildpacks": [ "buildpacks": [

View file

@ -1,7 +1,16 @@
# retain_original_contact_name: false / true
# In case of setUser we want to update the name of the identified contact,
# which is the default behaviour
#
# But, In case of contact merge during prechat form contact update.
# We don't want to update the name of the identified original contact.
class ContactIdentifyAction class ContactIdentifyAction
pattr_initialize [:contact!, :params!] pattr_initialize [:contact!, :params!, { retain_original_contact_name: false, discard_invalid_attrs: false }]
def perform def perform
@attributes_to_update = [:identifier, :name, :email, :phone_number]
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
merge_if_existing_identified_contact merge_if_existing_identified_contact
merge_if_existing_email_contact merge_if_existing_email_contact
@ -18,49 +27,89 @@ class ContactIdentifyAction
end end
def merge_if_existing_identified_contact def merge_if_existing_identified_contact
@contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact) return unless merge_contacts?(existing_identified_contact, :identifier)
process_contact_merge(existing_identified_contact)
end end
def merge_if_existing_email_contact def merge_if_existing_email_contact
@contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact) return unless merge_contacts?(existing_email_contact, :email)
process_contact_merge(existing_email_contact)
end end
def merge_if_existing_phone_number_contact def merge_if_existing_phone_number_contact
@contact = merge_contact(existing_phone_number_contact, @contact) if merge_contacts?(existing_phone_number_contact, @contact) return unless merge_contacts?(existing_phone_number_contact, :phone_number)
return unless mergable_phone_contact?
process_contact_merge(existing_phone_number_contact)
end
def process_contact_merge(mergee_contact)
@contact = merge_contact(mergee_contact, @contact)
@attributes_to_update.delete(:name) if retain_original_contact_name
end end
def existing_identified_contact def existing_identified_contact
return if params[:identifier].blank? return if params[:identifier].blank?
@existing_identified_contact ||= Contact.where(account_id: account.id).find_by(identifier: params[:identifier]) @existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier])
end end
def existing_email_contact def existing_email_contact
return if params[:email].blank? return if params[:email].blank?
@existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email]) @existing_email_contact ||= account.contacts.find_by(email: params[:email])
end end
def existing_phone_number_contact def existing_phone_number_contact
return if params[:phone_number].blank? return if params[:phone_number].blank?
@existing_phone_number_contact ||= Contact.where(account_id: account.id).find_by(phone_number: params[:phone_number]) @existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
end end
def merge_contacts?(existing_contact, _contact) def merge_contacts?(existing_contact, key)
existing_contact && existing_contact.id != @contact.id return if existing_contact.blank?
return true if params[:identifier].blank?
# we want to prevent merging contacts with different identifiers
if existing_contact.identifier.present? && existing_contact.identifier != params[:identifier]
# we will remove attribute from update list
@attributes_to_update.delete(key)
return false
end
true
end
# case: contact 1: email: 1@test.com, phone: 123456789
# params: email: 2@test.com, phone: 123456789
# we don't want to overwrite 1@test.com since email parameter takes higer priority
def mergable_phone_contact?
return true if params[:email].blank?
if existing_phone_number_contact.email.present? && existing_phone_number_contact.email != params[:email]
@attributes_to_update.delete(:phone_number)
return false
end
true
end end
def update_contact def update_contact
@contact.attributes = params.slice(*@attributes_to_update).reject do |_k, v|
v.blank?
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })
# blank identifier or email will throw unique index error # blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded # TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.update!(params.slice(:name, :email, :identifier, :phone_number).reject do |_k, v| @contact.discard_invalid_attrs if discard_invalid_attrs
v.blank? @contact.save!
end.merge({ custom_attributes: custom_attributes, additional_attributes: additional_attributes })) Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
def merge_contact(base_contact, merge_contact) def merge_contact(base_contact, merge_contact)
return base_contact if base_contact.id == merge_contact.id
ContactMergeAction.new( ContactMergeAction.new(
account: account, account: account,
base_contact: base_contact, base_contact: base_contact,
@ -69,14 +118,14 @@ class ContactIdentifyAction
end end
def custom_attributes def custom_attributes
params[:custom_attributes] ? @contact.custom_attributes.deep_merge(params[:custom_attributes].stringify_keys) : @contact.custom_attributes return @contact.custom_attributes if params[:custom_attributes].blank?
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
end end
def additional_attributes def additional_attributes
if params[:additional_attributes] return @contact.additional_attributes if params[:additional_attributes].blank?
@contact.additional_attributes.deep_merge(params[:additional_attributes].stringify_keys)
else (@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
@contact.additional_attributes
end
end end
end end

View file

@ -2,7 +2,7 @@
class AccountBuilder class AccountBuilder
include CustomExceptions::Account include CustomExceptions::Account
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin] pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
def perform def perform
if @user.nil? if @user.nil?

View file

@ -1,5 +1,5 @@
class Campaigns::CampaignConversationBuilder class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes] pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
def perform def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id) @contact_inbox = ContactInbox.find(@contact_inbox_id)
@ -9,19 +9,23 @@ 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
return 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
end end
@conversation @conversation
rescue StandardError => e
Rails.logger.info(e.message)
nil
end end
private private
def message_params def message_params
ActionController::Parameters.new({ ActionController::Parameters.new({
content: @campaign.message content: @campaign.message,
campaign_id: @campaign.id
}) })
end end
@ -32,7 +36,8 @@ class Campaigns::CampaignConversationBuilder
contact_id: @contact_inbox.contact_id, contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id, campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes additional_attributes: conversation_additional_attributes,
custom_attributes: custom_attributes || {}
} }
end end
end end

View file

@ -15,16 +15,15 @@ class ContactBuilder
end end
def create_contact_inbox(contact) def create_contact_inbox(contact)
::ContactInbox.create!( ::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
hmac_verified: hmac_verified || false
) )
end end
def update_contact_avatar(contact) def update_contact_avatar(contact)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url] ::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
end end
def create_contact def create_contact
@ -70,7 +69,7 @@ class ContactBuilder
update_contact_avatar(contact) update_contact_avatar(contact)
contact_inbox contact_inbox
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
raise e raise e
end end
end end

View file

@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
ensure_contact_avatar ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}" @inbox.channel.authorization_error!
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true true
end end
@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact.present? return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url)) @contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) @contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
def build_message def build_message
@ -58,7 +58,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if contact_params[:remote_avatar_url].blank? return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached? return if @contact.avatar.attached?
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end end
def conversation def conversation
@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
result = {} result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages # We don't need to capture this error as we don't care about contact params in case of echo messages
Sentry.capture_exception(e) unless @outgoing_echo ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
rescue StandardError => e rescue StandardError => e
result = {} result = {}
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
end end
process_contact_params_result(result) process_contact_params_result(result)
end end

View file

@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
@inbox.channel.authorization_error! @inbox.channel.authorization_error!
raise raise
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true true
end end

View file

@ -9,6 +9,7 @@ class Messages::MessageBuilder
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to) @in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@ -64,6 +65,18 @@ class Messages::MessageBuilder
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {} @params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end end
def automation_rule_id
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
end
def campaign_id
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
end
def template_params
@params[:template_params].present? ? { additional_attributes: { template_params: JSON.parse(@params[:template_params].to_json) } } : {}
end
def message_sender def message_sender
return if @params[:sender_type] != 'AgentBot' return if @params[:sender_type] != 'AgentBot'
@ -82,6 +95,6 @@ class Messages::MessageBuilder
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id] echo_id: @params[:echo_id]
}.merge(external_created_at) }.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end end
end end

View file

@ -53,16 +53,10 @@ class Messages::Messenger::MessageBuilder
def fetch_story_link(attachment) def fetch_story_link(attachment)
message = attachment.message message = attachment.message
begin result = get_story_object_from_source_id(message.source_id)
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(message.source_id, fields: %w[story from]) || {} return if result.blank?
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
story_id = result['story']['mention']['id'] story_id = result['story']['mention']['id']
story_sender = result['from']['username'] story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender message.content_attributes[:story_sender] = story_sender
@ -70,4 +64,20 @@ class Messages::Messenger::MessageBuilder
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender) message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save! message.save!
end end
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.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 end

View file

@ -15,6 +15,9 @@ class NotificationBuilder
def user_subscribed_to_notification? def user_subscribed_to_notification?
notification_setting = user.notification_settings.find_by(account_id: account.id) notification_setting = user.notification_settings.find_by(account_id: account.id)
# added for the case where an assignee might be removed from the account but remains in conversation
return if notification_setting.blank?
return true if notification_setting.public_send("email_#{notification_type}?") return true if notification_setting.public_send("email_#{notification_type}?")
return true if notification_setting.public_send("push_#{notification_type}?") return true if notification_setting.public_send("push_#{notification_type}?")

View file

@ -25,7 +25,7 @@ class NotificationSubscriptionBuilder
end end
def build_identifier_subscription def build_identifier_subscription
@identifier_subscription = user.notification_subscriptions.create(params.merge(identifier: identifier)) @identifier_subscription = user.notification_subscriptions.create!(params.merge(identifier: identifier))
end end
def update_identifier_subscription def update_identifier_subscription

View file

@ -1,8 +1,10 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper include DateRangeHelper
include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 25
def initialize(account, params) def initialize(account, params)
@account = account @account = account
@ -18,8 +20,14 @@ class V2::ReportBuilder
# For backward compatible with old report # For backward compatible with old report
def build def build
timeseries.each_with_object([]) do |p, arr| if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
arr << { value: p[1], timestamp: p[0].to_time.to_i } timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
end
else
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
end
end end
end end
@ -34,23 +42,16 @@ class V2::ReportBuilder
} }
end end
private def conversation_metrics
if params[:type].equal?(:account)
def scope conversations
case params[:type] else
when :account agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end end
end end
private
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@ -68,7 +69,7 @@ class V2::ReportBuilder
end end
def get_grouped_values(object_scope) def get_grouped_values(object_scope)
object_scope.group_by_period( @grouped_values = object_scope.group_by_period(
params[:group_by] || DEFAULT_GROUP_BY, params[:group_by] || DEFAULT_GROUP_BY,
:created_at, :created_at,
default_value: 0, default_value: 0,
@ -78,47 +79,29 @@ class V2::ReportBuilder
) )
end end
def conversations_count def agent_metrics
(get_grouped_values scope.conversations).count account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
account_users.each_with_object([]) do |account_user, arr|
@user = account_user.user
arr << {
id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
}
end
end end
def incoming_messages_count def conversations
(get_grouped_values scope.messages.incoming.unscope(:order)).count @open_conversations = scope.conversations.where(account_id: @account.id).open
end first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
def outgoing_messages_count open: @open_conversations.count,
(get_grouped_values scope.messages.outgoing.unscope(:order)).count unattended: @open_conversations.count - first_response_count
end }
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
def resolutions_count metric
(get_grouped_values scope.conversations.resolved).count
end
def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
end
def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
end
def avg_resolution_time_summary
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
return 0 if avg_rt.blank?
avg_rt
end
def avg_first_response_time_summary
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)
return 0 if avg_frt.blank?
avg_frt
end end
end end

View file

@ -1,5 +1,8 @@
class RoomChannel < ApplicationCable::Channel class RoomChannel < ApplicationCable::Channel
def subscribed def subscribed
# 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
ensure_stream ensure_stream
current_user current_user
current_account current_account
@ -15,6 +18,8 @@ class RoomChannel < ApplicationCable::Channel
private private
def broadcast_presence def broadcast_presence
return if @current_account.blank?
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 })
@ -26,6 +31,8 @@ class RoomChannel < ApplicationCable::Channel
end end
def update_subscription def update_subscription
return if @current_account.blank?
::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
@ -38,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel
end end
def current_account def current_account
return if current_user.blank?
@current_account ||= if @current_user.is_a? Contact @current_account ||= if @current_user.is_a? Contact
@current_user.account @current_user.account
else else

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@agent_bot.destroy @agent_bot.destroy!
head :ok head :ok
end end
@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url) params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end end
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@agent.current_account_user.destroy @agent.current_account_user.destroy!
head :ok head :ok
end end
@ -39,7 +39,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
# TODO: move this to a builder and combine the save account user method into a builder # TODO: move this to a builder and combine the save account user method into a builder
# ensure the account user association is also created in a single transaction # ensure the account user association is also created in a single transaction
def create_user def create_user
return if @user return @user.send_confirmation_instructions if @user
@user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation)) @user = User.create!(new_agent_params.slice(:email, :name, :password, :password_confirmation))
end end
@ -68,10 +68,10 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
end end
def validate_limit def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents] render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end end
end end

View file

@ -0,0 +1,57 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create]
before_action :set_current_page, only: [:index]
def index
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def edit; end
def show; end
def update
@article.update!(article_params)
end
def destroy
@article.destroy!
head :ok
end
private
def fetch_article
@article = @portal.articles.find(params[:id])
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def article_params
params.require(:article).permit(
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
{ tags: [] }]
)
end
def list_params
params.permit(:locale, :query, :page, :category_slug, :status, :author_id)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View file

@ -0,0 +1,24 @@
class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseController
before_action :fetch_inboxes
def index
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
member_ids
end
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
end
private
def fetch_inboxes
@inboxes = Current.account.inboxes.find(permitted_params[:inbox_ids])
end
def permitted_params
params.permit(inbox_ids: [])
end
end

View file

@ -7,13 +7,38 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
end end
def create def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit) @automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save!
process_attachments
@automation_rule
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end end
def show; end def show; end
def update def update
@automation_rule.update(automation_rules_permit) ActiveRecord::Base.transaction do
automation_rule_update
process_attachments
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end end
def destroy def destroy
@ -24,16 +49,34 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def clone def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id]) automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup new_rule = automation_rule.dup
new_rule.save new_rule.save!
@automation_rule = new_rule @automation_rule = new_rule
end end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :account_id, :active, :name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }] actions: [:action_name, { action_params: [] }]
) )
end end

View file

@ -1,32 +1,6 @@
class Api::V1::Accounts::BaseController < Api::BaseController class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account before_action :current_account
around_action :switch_locale_using_account_locale around_action :switch_locale_using_account_locale
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
end end

View file

@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(page_access_token, facebook_channel) set_instagram_id(page_access_token, facebook_channel)
set_avatar(@facebook_inbox, page_id) set_avatar(@facebook_inbox, page_id)
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
end end
end end
@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
set_instagram_id(access_token, fb_page) set_instagram_id(access_token, fb_page)
fb_page&.reauthorized! fb_page&.reauthorized!
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
end end
end end
@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
def mark_already_existing_facebook_pages(data) def mark_already_existing_facebook_pages(data)
@ -90,9 +90,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def set_avatar(facebook_inbox, page_id) def set_avatar(facebook_inbox, page_id)
avatar_file = Down.download( avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large"
"http://graph.facebook.com/#{page_id}/picture?type=large" Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
)
facebook_inbox.avatar.attach(io: avatar_file, filename: avatar_file.original_filename, content_type: avatar_file.content_type)
end end
end end

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@campaign.destroy @campaign.destroy!
head :ok head :ok
end end

View file

@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
end end
def destroy def destroy
@canned_response.destroy @canned_response.destroy!
head :ok head :ok
end end

View file

@ -0,0 +1,57 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :set_current_page, only: [:index]
def index
@categories = @portal.categories.search(params)
end
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
render json: { error: @category.errors.messages }, status: :unprocessable_entity and return unless @category.valid?
@category.save!
end
def destroy
@category.destroy!
head :ok
end
private
def fetch_category
@category = @portal.categories.find(params[:id])
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
end
def related_categories_records
@portal.categories.where(id: params[:category][:related_category_ids])
end
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :parent_category_id, :associated_category_id
)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View file

@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
build_inbox build_inbox
setup_webhooks if @twilio_channel.sms? setup_webhooks if @twilio_channel.sms?
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
end end
@ -28,6 +27,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end end
def phone_number def phone_number
return if permitted_params[:phone_number].blank?
medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}" medium == 'sms' ? permitted_params[:phone_number] : "whatsapp:#{permitted_params[:phone_number]}"
end end
@ -39,10 +40,11 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
@twilio_channel = Current.account.twilio_sms.create!( @twilio_channel = Current.account.twilio_sms.create!(
account_sid: permitted_params[:account_sid], account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token], auth_token: permitted_params[:auth_token],
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
phone_number: phone_number, phone_number: phone_number,
medium: medium medium: medium
) )
@inbox = Current.account.inboxes.create( @inbox = Current.account.inboxes.create!(
name: permitted_params[:name], name: permitted_params[:name],
channel: @twilio_channel channel: @twilio_channel
) )
@ -50,7 +52,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params def permitted_params
params.require(:twilio_channel).permit( params.require(:twilio_channel).permit(
:account_id, :phone_number, :account_sid, :auth_token, :name, :medium :account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
) )
end end
end end

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
end end
def destroy def destroy
@note.destroy @note.destroy!
head :ok head :ok
end end

View file

@ -12,7 +12,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, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter] before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@ -25,7 +25,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = resolved_contacts.where( contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q]}%" search: "%#{params[:q].strip}%"
) )
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts_with_conversation_count(contacts) @contacts = fetch_contacts_with_conversation_count(contacts)
@ -72,15 +72,17 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params) @contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save! @contact.save!
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
process_avatar
end end
end end
def update def update
@contact.assign_attributes(contact_update_params) @contact.assign_attributes(contact_update_params)
@contact.save! @contact.save!
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
end end
def destroy def destroy
@ -95,6 +97,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def avatar
@contact.avatar.purge if @contact.avatar.attached?
@contact
end
private private
# TODO: Move this to a finder class # TODO: Move this to a finder class
@ -128,22 +135,22 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
inbox = Current.account.inboxes.find(params[:inbox_id]) inbox = Current.account.inboxes.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id) ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
end end
def contact_params def permitted_params
params.require(:contact).permit(:name, :identifier, :email, :phone_number, additional_attributes: {}, custom_attributes: {}) params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {})
end end
def contact_custom_attributes def contact_custom_attributes
return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_params[:custom_attributes] return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes]
@contact.custom_attributes @contact.custom_attributes
end end
def contact_update_params def contact_update_params
# we want the merged custom attributes not the original one # we want the merged custom attributes not the original one
contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes }) permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes })
end end
def set_include_contact_inboxes def set_include_contact_inboxes
@ -158,6 +165,10 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end end
def process_avatar
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
def render_error(error, error_status) def render_error(error, error_status)
render json: error, status: error_status render json: error, status: error_status
end end

View file

@ -1,10 +1,11 @@
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
include EnsureCurrentAccountHelper
before_action :conversation before_action :conversation
private private
def conversation def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation.inbox, :show? authorize @conversation.inbox, :show?
end end
end end

View file

@ -0,0 +1,17 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
before_action :current_account
before_action :conversation
def create
return if @conversation.nil? || @current_account.nil?
super
end
private
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
end
end

View file

@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def destroy def destroy
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true) message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
message.attachments.destroy_all message.attachments.destroy_all
end end
end end

View file

@ -5,7 +5,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
RESULTS_PER_PAGE = 25 RESULTS_PER_PAGE = 25
before_action :check_authorization before_action :check_authorization
before_action :set_csat_survey_responses, only: [:index, :metrics] before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
before_action :set_current_page_surveys, only: [:index] before_action :set_current_page_surveys, only: [:index]
before_action :set_total_sent_messages_count, only: [:metrics] before_action :set_total_sent_messages_count, only: [:metrics]
@ -19,6 +19,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@ratings_count = @csat_survey_responses.group(:rating).count @ratings_count = @csat_survey_responses.group(:rating).count
end end
def download
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
end
private private
def set_total_sent_messages_count def set_total_sent_messages_count

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
end end
def destroy def destroy
@custom_attribute_definition.destroy @custom_attribute_definition.destroy!
head :no_content head :no_content
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
end end
def destroy def destroy
@custom_filter.destroy @custom_filter.destroy!
head :no_content head :no_content
end end

View file

@ -0,0 +1,44 @@
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
before_action :fetch_dashboard_apps, except: [:create]
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
def index; end
def show; end
def create
@dashboard_app = Current.account.dashboard_apps.create!(
permitted_payload.merge(user_id: Current.user.id)
)
end
def update
@dashboard_app.update!(permitted_payload)
end
def destroy
@dashboard_app.destroy!
head :no_content
end
private
def fetch_dashboard_apps
@dashboard_apps = Current.account.dashboard_apps
end
def fetch_dashboard_app
@dashboard_app = @dashboard_apps.find(permitted_params[:id])
end
def permitted_payload
params.require(:dashboard_app).permit(
:title,
content: [:url, :type]
)
end
def permitted_params
params.permit(:id)
end
end

View file

@ -12,6 +12,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def show; end def show; end
# Deprecated: This API will be removed in 2.7.0
def assignable_agents def assignable_agents
@assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq @assignable_agents = (Current.account.users.where(id: @inbox.members.select(:user_id)) + Current.account.administrators).uniq
end end
@ -41,18 +42,30 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
@inbox.update(permitted_params.except(:channel)) @inbox.update!(permitted_params.except(:channel))
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type) channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes # Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank? return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email' if @inbox.inbox_type == 'Email'
begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel]) @inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags update_channel_feature_flags
end end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
def agent_bot def agent_bot
@agent_bot = @inbox.agent_bot @agent_bot = @inbox.agent_bot
end end
@ -69,7 +82,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@inbox.destroy @inbox.destroy!
head :ok head :ok
end end
@ -84,12 +97,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def inbox_name(channel)
return channel.try(:bot_name) if channel.is_a?(Channel::Telegram)
permitted_params[:name]
end
def create_channel def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
@ -104,10 +111,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@inbox.channel.save! @inbox.channel.save!
end end
def inbox_attributes
[: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]
end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])
params.permit( params.permit(
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, *inbox_attributes,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
channel: [:type, *channel_attributes] channel: [:type, *channel_attributes]
) )
end end
@ -124,18 +135,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
}[permitted_params[:channel][:type]] }[permitted_params[:channel][:type]]
end end
def account_channels_method
{
'web_widget' => Current.account.web_widgets,
'api' => Current.account.api_channels,
'email' => Current.account.email_channels,
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence channel_type.constantize::EDITABLE_ATTRS.presence
@ -143,10 +142,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
[] []
end end
end end
def validate_limit
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
end
end end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end end
def destroy def destroy
@hook.destroy @hook.destroy!
head :ok head :ok
end end

View file

@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end end
def destroy def destroy
@hook.destroy @hook.destroy!
head :ok head :ok
end end

View file

@ -1,9 +0,0 @@
class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseController
before_action :portal
private
def portal
@portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id])
end
end

View file

@ -1,32 +0,0 @@
class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase::BaseController
before_action :fetch_category, except: [:index, :create]
def index
@categories = @portal.categories
end
def create
@category = @portal.categories.create!(category_params)
end
def update
@category.update!(category_params)
end
def destroy
@category.destroy
head :ok
end
private
def fetch_category
@category = @portal.categories.find(params[:id])
end
def category_params
params.require(:category).permit(
:name, :description, :position
)
end
end

View file

@ -1,32 +0,0 @@
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController
before_action :fetch_portal, except: [:index, :create]
def index
@portals = Current.account.kbase_portals
end
def create
@portal = Current.account.kbase_portals.create!(portal_params)
end
def update
@portal.update!(portal_params)
end
def destroy
@portal.destroy
head :ok
end
private
def fetch_portal
@portal = current_account.kbase_portals.find(params[:id])
end
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug
)
end
end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@label.destroy @label.destroy!
head :ok head :ok
end end

View file

@ -0,0 +1,59 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
def index
@macros = Macro.with_visibility(current_user, params)
end
def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = params[:actions]
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
end
def show
head :not_found if @macro.nil?
end
def destroy
@macro.destroy!
head :ok
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
head :ok
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
def macros_with_user
permitted_params.merge(updated_by_id: current_user.id)
end
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
end

View file

@ -0,0 +1,73 @@
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
include ::FileTypeHelper
before_action :fetch_portal, except: [:index, :create]
before_action :check_authorization
before_action :set_current_page, only: [:index]
def index
@portals = Current.account.portals
end
def add_members
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
@portal.members << agents
end
def show; end
def create
@portal = Current.account.portals.build(portal_params)
@portal.save!
process_attached_logo
end
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
process_attached_logo
rescue StandardError => e
Rails.logger.error e
render json: { error: @portal.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@portal.destroy!
head :ok
end
def archive
@portal.update(archive: true)
head :ok
end
def process_attached_logo
@portal.logo.attach(params[:logo])
end
private
def fetch_portal
@portal = Current.account.portals.find_by(slug: permitted_params[:id])
end
def permitted_params
params.permit(:id)
end
def portal_params
params.require(:portal).permit(
:account_id, :color, :custom_domain, :header_text, :homepage_link, :name, :page_title, :slug, :archived, { config: [:default_locale,
{ allowed_locales: [] }] }
)
end
def portal_member_params
params.require(:portal).permit(:account_id, member_ids: [])
end
def set_current_page
@current_page = params[:page] || 1
end
end

View file

@ -1,6 +1,7 @@
class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseController class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseController
before_action :fetch_team before_action :fetch_team
before_action :check_authorization before_action :check_authorization
before_action :validate_member_id_params, only: [:create, :update, :destroy]
def index def index
@team_members = @team.team_members.map(&:user) @team_members = @team.team_members.map(&:user)
@ -45,4 +46,10 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def fetch_team def fetch_team
@team = Current.account.teams.find(params[:team_id]) @team = Current.account.teams.find(params[:team_id])
end end
def validate_member_id_params
invalid_ids = params[:user_ids].map(&:to_i) - @team.account.user_ids
render json: { error: 'Invalid User IDs' }, status: :unauthorized and return if invalid_ids.present?
end
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@team.destroy @team.destroy!
head :ok head :ok
end end

View file

@ -16,14 +16,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@webhook.destroy @webhook.destroy!
head :ok head :ok
end end
private private
def webhook_params def webhook_params
params.require(:webhook).permit(:inbox_id, :url) params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end end
def fetch_webhook def fetch_webhook

View file

@ -19,6 +19,7 @@ class Api::V1::AccountsController < Api::BaseController
user_full_name: account_params[:user_full_name], user_full_name: account_params[:user_full_name],
email: account_params[:email], email: account_params[:email],
user_password: account_params[:password], user_password: account_params[:password],
locale: account_params[:locale],
user: current_user user: current_user
).perform ).perform
if @user if @user

View file

@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy notification_subscription.destroy! if notification_subscription.present?
head :ok head :ok
end end

View file

@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController
twitter_consumer.consume twitter_consumer.consume
head :ok head :ok
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) ChatwootExceptionTracker.new(e).capture_exception
head :ok head :ok
end end

View file

@ -1,5 +1,6 @@
class Api::V1::Widget::BaseController < ApplicationController class Api::V1::Widget::BaseController < ApplicationController
include SwitchLocale include SwitchLocale
include WebsiteTokenHelper
before_action :set_web_widget before_action :set_web_widget
before_action :set_contact before_action :set_contact
@ -19,23 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController
@conversation ||= conversations.last @conversation ||= conversations.last
end end
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
end
def create_conversation def create_conversation
::Conversation.create!(conversation_params) ::Conversation.create!(conversation_params)
end end
@ -52,36 +36,25 @@ class Api::V1::Widget::BaseController < ApplicationController
contact_id: @contact.id, contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: { additional_attributes: {
browser_language: browser.accept_language&.first&.code,
browser: browser_params, browser: browser_params,
referer: permitted_params[:message][:referer_url], initiated_at: timestamp_params,
initiated_at: timestamp_params referer: permitted_params[:message][:referer_url]
} },
custom_attributes: permitted_params[:custom_attributes].presence || {}
} }
end end
def update_contact(email)
contact_with_email = @current_account.contacts.find_by(email: email)
if contact_with_email
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_email,
mergee_contact: @contact
).perform
else
@contact.update!(email: email, name: contact_name, phone_number: contact_phone_number)
end
end
def contact_email def contact_email
permitted_params[:contact][:email].downcase permitted_params.dig(:contact, :email)&.downcase
end end
def contact_name def contact_name
params[:contact][:name] || contact_email.split('@')[0] params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end end
def contact_phone_number def contact_phone_number
params[:contact][:phone_number] permitted_params.dig(:contact, :phone_number)
end end
def browser_params def browser_params
@ -98,10 +71,6 @@ class Api::V1::Widget::BaseController < ApplicationController
{ timestamp: permitted_params[:message][:timestamp] } { timestamp: permitted_params[:message][:timestamp] }
end end
def permitted_params
params.permit(:website_token)
end
def message_params def message_params
{ {
account_id: conversation.account_id, account_id: conversation.account_id,

View file

@ -1,14 +1,27 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac, only: [:update] include WidgetHelper
before_action :validate_hmac, only: [:set_user]
def show; end def show; end
def update def update
contact_identify_action = ContactIdentifyAction.new( identify_contact(@contact)
contact: @contact, end
params: permitted_params.to_h.deep_symbolize_keys
) def set_user
@contact = contact_identify_action.perform contact = nil
if a_different_contact?
@contact_inbox, @widget_auth_token = build_contact_inbox_with_token(@web_widget)
contact = @contact_inbox.contact
else
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
identify_contact(contact)
end end
# TODO : clean up this with proper routes delete contacts/custom_attributes # TODO : clean up this with proper routes delete contacts/custom_attributes
@ -20,12 +33,23 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
private private
def process_hmac def identify_contact(contact)
contact_identify_action = ContactIdentifyAction.new(
contact: contact,
params: permitted_params.to_h.deep_symbolize_keys,
discard_invalid_attrs: true
)
@contact = contact_identify_action.perform
end
def a_different_contact?
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
def validate_hmac
return unless should_verify_hmac? return unless should_verify_hmac?
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac? render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
@contact_inbox.update(hmac_verified: true)
end end
def should_verify_hmac? def should_verify_hmac?

View file

@ -7,12 +7,20 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank? && contact_email.present? process_update_contact
@conversation = create_conversation @conversation = create_conversation
conversation.messages.create(message_params) conversation.messages.create!(message_params)
end end
end end
def process_update_contact
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
).perform
end
def update_last_seen def update_last_seen
head :ok && return if conversation.nil? head :ok && return if conversation.nil?
@ -44,6 +52,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok head :ok
end end
def toggle_status
return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved?
conversation.status = :resolved
conversation.save!
end
head :ok
end
private private
def trigger_typing_event(event) def trigger_typing_event(event)
@ -52,6 +72,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def permitted_params def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number], params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id]) message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end end
end end

View file

@ -0,0 +1,11 @@
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
include WebsiteTokenHelper
before_action :set_web_widget
before_action :set_contact
def create
return if @contact.nil? || @current_account.nil?
super
end
end

View file

@ -15,7 +15,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def update def update
if @message.content_type == 'input_email' if @message.content_type == 'input_email'
@message.update!(submitted_email: contact_email) @message.update!(submitted_email: contact_email)
update_contact(contact_email) ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email }
).perform
else else
@message.update!(message_update_params[:message]) @message.update!(message_update_params[:message])
end end

View file

@ -1,4 +1,5 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
before_action :check_authorization before_action :check_authorization
def index def index
@ -12,64 +13,80 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end end
def agents def agents
response.headers['Content-Type'] = 'text/csv' @report_data = generate_agents_report
response.headers['Content-Disposition'] = 'attachment; filename=agents_report.csv' generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/agents.csv.erb', format: 'csv'
end end
def inboxes def inboxes
response.headers['Content-Type'] = 'text/csv' @report_data = generate_inboxes_report
response.headers['Content-Disposition'] = 'attachment; filename=inboxes_report.csv' generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/inboxes.csv.erb', format: 'csv'
end end
def labels def labels
response.headers['Content-Type'] = 'text/csv' @report_data = generate_labels_report
response.headers['Content-Disposition'] = 'attachment; filename=labels_report.csv' generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv'
end end
def teams def teams
response.headers['Content-Type'] = 'text/csv' @report_data = generate_teams_report
response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb')
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' end
def conversations
return head :unprocessable_entity if params[:type].blank?
render json: conversation_metrics
end end
private private
def generate_csv(filename, template)
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
render layout: false, template: template, format: 'csv'
end
def check_authorization def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator? raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end end
def current_summary_params def common_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
id: params[:id], id: params[:id],
since: range[:current][:since], group_by: params[:group_by],
until: range[:current][:until], business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
group_by: params[:group_by]
} }
end end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params def previous_summary_params
{ common_params.merge({
type: params[:type].to_sym, since: range[:previous][:since],
id: params[:id], until: range[:previous][:until]
since: range[:previous][:since], })
until: range[:previous][:until],
group_by: params[:group_by]
}
end end
def report_params def report_params
common_params.merge({
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone_offset: params[:timezone_offset]
})
end
def conversation_params
{ {
metric: params[:metric],
type: params[:type].to_sym, type: params[:type].to_sym,
since: params[:since], user_id: params[:user_id],
until: params[:until], page: params[:page].presence || 1
id: params[:id],
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
} }
end end
@ -91,4 +108,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary summary
end end
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end end

View file

@ -1,7 +1,7 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include DeviseTokenAuth::Concerns::SetUserByToken include DeviseTokenAuth::Concerns::SetUserByToken
include RequestExceptionHandler include RequestExceptionHandler
include Pundit include Pundit::Authorization
include SwitchLocale include SwitchLocale
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
@ -17,10 +17,6 @@ class ApplicationController < ActionController::Base
Current.user = @user Current.user = @user
end end
def current_subscription
@subscription ||= Current.account.subscription
end
def pundit_user def pundit_user
{ {
user: Current.user, user: Current.user,

View file

@ -0,0 +1,34 @@
module EnsureCurrentAccountHelper
private
def current_account
@current_account ||= ensure_current_account
Current.account = @current_account
end
def ensure_current_account
account = Account.find(params[:account_id])
ensure_account_is_active?(account)
if current_user
account_accessible_for_user?(account)
elsif @resource.is_a?(AgentBot)
account_accessible_for_bot?(account)
end
account
end
def account_accessible_for_user?(account)
@current_account_user = account.account_users.find_by(user_id: current_user.id)
Current.account_user = @current_account_user
render_unauthorized('You are not authorized to access this account') unless @current_account_user
end
def account_accessible_for_bot?(account)
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
end
def ensure_account_is_active?(account)
render_unauthorized('Account is suspended') unless account.active?
end
end

View file

@ -0,0 +1,20 @@
# services from Meta (Prev: Facebook) needs a token verification step for webhook subscriptions,
# This concern handles the token verification step.
module MetaTokenVerifyConcern
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
Rails.logger.info("#{service.capitalize} webhook verified")
render json: params['hub.challenge']
else
render status: :unauthorized, json: { error: 'Error; wrong verify token' }
end
end
private
def valid_token?(_token)
raise 'Overwrite this method your controller'
end
end

View file

@ -9,8 +9,7 @@ module RequestExceptionHandler
def handle_with_exception def handle_with_exception
yield yield
rescue ActiveRecord::RecordNotFound => e rescue ActiveRecord::RecordNotFound
Sentry.capture_exception(e)
render_not_found_error('Resource could not be found') render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action') render_unauthorized('You are not authorized to do this action')

View file

@ -0,0 +1,26 @@
module WebsiteTokenHelper
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@current_account = @web_widget.inbox.account
render json: { error: 'Account is suspended' }, status: :unauthorized unless @current_account.active?
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox&.contact
raise ActiveRecord::RecordNotFound unless @contact
Current.contact = @contact
end
def permitted_params
params.permit(:website_token)
end
end

View file

@ -4,6 +4,7 @@ class DashboardController < ActionController::Base
before_action :set_global_config before_action :set_global_config
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]
layout 'vueapp' layout 'vueapp'
@ -13,8 +14,7 @@ class DashboardController < ActionController::Base
def set_global_config def set_global_config
@global_config = GlobalConfig.get( @global_config = GlobalConfig.get(
'LOGO', 'LOGO', 'LOGO_THUMBNAIL',
'LOGO_THUMBNAIL',
'INSTALLATION_NAME', 'INSTALLATION_NAME',
'WIDGET_BRAND_URL', 'WIDGET_BRAND_URL',
'TERMS_URL', 'TERMS_URL',
@ -29,7 +29,8 @@ class DashboardController < ActionController::Base
'DIRECT_UPLOADS_ENABLED', 'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY', 'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK', 'LOGOUT_REDIRECT_LINK',
'DISABLE_USER_PROFILE_UPDATE' 'DISABLE_USER_PROFILE_UPDATE',
'DEPLOYMENT_ENV'
).merge(app_config) ).merge(app_config)
end end
@ -37,10 +38,25 @@ class DashboardController < ActionController::Base
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING) redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end end
def render_hc_if_custom_domain
domain = request.host
return if domain == URI.parse(ENV.fetch('FRONTEND_URL', '')).host
@portal = Portal.find_by(custom_domain: domain)
return unless @portal
@locale = @portal.default_locale
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
def app_config def app_config
{ APP_VERSION: Chatwoot.config[:version], {
APP_VERSION: Chatwoot.config[:version],
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', ''),
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?
}
end end
end end

View file

@ -23,7 +23,7 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
def authenticate_resource_with_sso_token def authenticate_resource_with_sso_token
@token = @resource.create_token @token = @resource.create_token
@resource.save @resource.save!
sign_in(:user, @resource, store: false, bypass: false) sign_in(:user, @resource, store: false, bypass: false)
# invalidate the token after the user is signed in # invalidate the token after the user is signed in

View file

@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
end end
def destroy def destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
head :ok head :ok
end end

View file

@ -27,6 +27,6 @@ class Platform::Api::V1::AccountsController < PlatformController
end end
def account_params def account_params
params.permit(:name) params.permit(:name, :locale)
end end
end end

View file

@ -7,20 +7,24 @@ class Platform::Api::V1::UsersController < PlatformController
def create def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) @resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@resource.save! @resource.save!
@resource.confirm @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end end
def login def login
encoded_email = ERB::Util.url_encode(@resource.email) encoded_email = ERB::Util.url_encode(@resource.email)
render json: { url: "#{ENV['FRONTEND_URL']}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" } render json: { url: "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{@resource.generate_sso_auth_token}" }
end end
def show; end def show; end
def update def update
@resource.assign_attributes(user_update_params) @resource.assign_attributes(user_update_params)
# We are using devise's reconfirmable flow for changing emails
# But in case of platform APIs we don't want user to go through this extra step
@resource.skip_reconfirmation! if user_update_params[:email].present?
@resource.save! @resource.save!
end end

View file

@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end end
def permitted_params def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {}) params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
end end
end end

View file

@ -0,0 +1,39 @@
class Public::Api::V1::Portals::ArticlesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category
before_action :set_article, only: [:show]
layout 'portal'
def index
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
end
def show; end
private
def set_article
@article = @category.articles.find(params[:id])
@parsed_content = render_article_content(@article.content)
end
def set_category
@category = @portal.categories.find_by!(slug: params[:category_slug])
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
def list_params
params.permit(:query)
end
def render_article_content(content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
end
end

View file

@ -0,0 +1,22 @@
class Public::Api::V1::Portals::CategoriesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, only: [:show]
layout 'portal'
def index
@categories = @portal.categories
end
def show; end
private
def set_category
@category = @portal.categories.find_by!(locale: params[:locale], slug: params[:category_slug])
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
end

View file

@ -0,0 +1,21 @@
class Public::Api::V1::PortalsController < PublicController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show; end
private
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
@locale = params[:locale] || @portal.default_locale
end
def redirect_to_portal_with_locale
return if params[:locale].present?
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end

View file

@ -3,4 +3,20 @@
class PublicController < ActionController::Base class PublicController < ActionController::Base
include RequestExceptionHandler include RequestExceptionHandler
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
private
def ensure_custom_domain_request
domain = request.host
return if [URI.parse(ENV.fetch('FRONTEND_URL', '')).host, URI.parse(ENV.fetch('HELPCENTER_URL', '')).host].include?(domain)
@portal = ::Portal.find_by(custom_domain: domain)
return if @portal.present?
render json: {
error: "Domain: #{domain} is not registered with us. \
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end
end end

View file

@ -36,9 +36,15 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def resource_params def resource_params
permitted_params = super permitted_params = super
permitted_params[:limits] = permitted_params[:limits].to_h.compact permitted_params[:limits] = permitted_params[:limits].to_h.compact
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params permitted_params
end end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions # See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information # for more information
def seed
Internal::SeedAccountJob.perform_later(requested_resource)
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
end
end end

View file

@ -7,7 +7,7 @@ class Twilio::CallbackController < ApplicationController
private private
def permitted_params def permitted_params # rubocop:disable Metrics/MethodLength
params.permit( params.permit(
:ApiVersion, :ApiVersion,
:SmsSid, :SmsSid,
@ -25,7 +25,8 @@ class Twilio::CallbackController < ApplicationController
:ToCountry, :ToCountry,
:FromState, :FromState,
:MediaUrl0, :MediaUrl0,
:MediaContentType0 :MediaContentType0,
:MessagingServiceSid
) )
end end
end end

View file

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
redirect_to twitter_app_redirect_url redirect_to twitter_app_redirect_url
end end
@ -44,12 +44,12 @@ class Twitter::CallbacksController < Twitter::BaseController
end end
def create_inbox def create_inbox
twitter_profile = account.twitter_profiles.create( twitter_profile = account.twitter_profiles.create!(
twitter_access_token: parsed_body['oauth_token'], twitter_access_token: parsed_body['oauth_token'],
twitter_access_token_secret: parsed_body['oauth_token_secret'], twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id'] profile_id: parsed_body['user_id']
) )
account.inboxes.create( account.inboxes.create!(
name: parsed_body['screen_name'], name: parsed_body['screen_name'],
channel: twitter_profile channel: twitter_profile
) )

View file

@ -1,15 +1,5 @@
class Webhooks::InstagramController < ApplicationController class Webhooks::InstagramController < ActionController::API
skip_before_action :authenticate_user!, raise: false include MetaTokenVerifyConcern
skip_before_action :set_current_user
def verify
if valid_instagram_token?(params['hub.verify_token'])
Rails.logger.info('Instagram webhook verified')
render json: params['hub.challenge']
else
render json: { error: 'Error; wrong verify token', status: 403 }
end
end
def events def events
Rails.logger.info('Instagram webhook received events') Rails.logger.info('Instagram webhook received events')
@ -17,14 +7,14 @@ class Webhooks::InstagramController < ApplicationController
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok render json: :ok
else else
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
head :unprocessable_entity head :unprocessable_entity
end end
end end
private private
def valid_instagram_token?(token) def valid_token?(token)
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
end end
end end

View file

@ -1,6 +1,16 @@
class Webhooks::WhatsappController < ActionController::API class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok head :ok
end end
private
def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
end end

View file

@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
before_action :ensure_widget_position before_action :ensure_widget_position
before_action :ensure_widget_type before_action :ensure_widget_type
before_action :ensure_widget_style before_action :ensure_widget_style
before_action :ensure_dark_mode
def index def index
render render
@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
@widget_style = params[:widget_style] || 'standard' @widget_style = params[:widget_style] || 'standard'
end end
def ensure_dark_mode
@dark_mode = params[:dark_mode] || 'light'
end
def ensure_widget_position def ensure_widget_position
@widget_position = params[:position] || 'left' @widget_position = params[:position] || 'left'
end end

View file

@ -1,7 +1,10 @@
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged # TODO : Delete this and associated spec once 'api/widget/config' end point is merged
class WidgetsController < ActionController::Base class WidgetsController < ActionController::Base
include WidgetHelper
before_action :set_global_config before_action :set_global_config
before_action :set_web_widget before_action :set_web_widget
before_action :ensure_account_is_active
before_action :set_token before_action :set_token
before_action :set_contact before_action :set_contact
before_action :build_contact before_action :build_contact
@ -40,11 +43,12 @@ class WidgetsController < ActionController::Base
def build_contact def build_contact
return if @contact.present? return if @contact.present?
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes) @contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact @contact = @contact_inbox.contact
end
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id } def ensure_account_is_active
@token = ::Widget::TokenService.new(payload: payload).generate_token render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
end end
def additional_attributes def additional_attributes

View file

@ -8,7 +8,15 @@ class AccountDashboard < Administrate::BaseDashboard
# which determines how the attribute is displayed # which determines how the attribute is displayed
# on pages throughout the dashboard. # on pages throughout the dashboard.
enterprise_attribute_types = ChatwootApp.enterprise? ? { limits: Enterprise::AccountLimitsField } : {} enterprise_attribute_types = if ChatwootApp.enterprise?
{
limits: Enterprise::AccountLimitsField,
all_features: Enterprise::AccountFeaturesField
}
else
{}
end
ATTRIBUTE_TYPES = { ATTRIBUTE_TYPES = {
id: Field::Number, id: Field::Number,
name: Field::String, name: Field::String,
@ -17,6 +25,7 @@ class AccountDashboard < Administrate::BaseDashboard
users: CountField, users: CountField,
conversations: CountField, conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
account_users: Field::HasMany account_users: Field::HasMany
}.merge(enterprise_attribute_types).freeze }.merge(enterprise_attribute_types).freeze
@ -31,17 +40,19 @@ class AccountDashboard < Administrate::BaseDashboard
locale locale
users users
conversations conversations
status
].freeze ].freeze
# SHOW_PAGE_ATTRIBUTES # SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page. # an array of attributes that will be displayed on the model's show page.
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits] : [] enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
SHOW_PAGE_ATTRIBUTES = (%i[ SHOW_PAGE_ATTRIBUTES = (%i[
id id
name name
created_at created_at
updated_at updated_at
locale locale
status
conversations conversations
account_users account_users
] + enterprise_show_page_attributes).freeze ] + enterprise_show_page_attributes).freeze
@ -49,10 +60,11 @@ class AccountDashboard < Administrate::BaseDashboard
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
# an array of attributes that will be displayed # an array of attributes that will be displayed
# on the model's form (`new` and `edit`) pages. # on the model's form (`new` and `edit`) pages.
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits] : [] enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
FORM_ATTRIBUTES = (%i[ FORM_ATTRIBUTES = (%i[
name name
locale locale
status
] + enterprise_form_attributes).freeze ] + enterprise_form_attributes).freeze
# COLLECTION_FILTERS # COLLECTION_FILTERS

View file

@ -6,7 +6,7 @@ class ConversationDrop < BaseDrop
end end
def contact_name def contact_name
@obj.try(:contact).name.capitalize || 'Customer' @obj.try(:contact).name.try(:capitalize) || 'Customer'
end end
def recent_messages def recent_messages

View file

@ -2,6 +2,8 @@ require 'administrate/field/base'
class AvatarField < Administrate::Field::Base class AvatarField < Administrate::Field::Base
def avatar_url def avatar_url
data.presence&.gsub('?d=404', '?d=mp') return data.presence if data.presence
resource.is_a?(User) ? '/assets/administrate/user/avatar.png' : '/assets/administrate/bot/avatar.png'
end end
end end

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