Compare commits

..

1 commit

Author SHA1 Message Date
Pranav Raj S
74a512e3a1 chore: Remove size from Heroku formation guide 2021-04-22 14:50:14 +05:30
3890 changed files with 27626 additions and 300693 deletions

View file

@ -1,3 +0,0 @@
---
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)

View file

@ -7,20 +7,16 @@ 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.4-browsers - image: circleci/ruby:2.7.2-node-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: cimg/postgres:14.1 - image: circleci/postgres:alpine
- image: cimg/redis:6.2.6 - image: circleci/redis:alpine
environment: environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs: jobs:
build: build:
<<: *defaults <<: *defaults
@ -44,19 +40,20 @@ jobs:
- restore_cache: - restore_cache:
keys: keys:
- chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }} - chatwoot-bundle-{{ 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 }}-v20220524-{{ checksum "Gemfile.lock" }} key: chatwoot-bundle-{{ 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
- restore_cache: - restore_cache:
keys: keys:
- chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} - chatwoot-yarn-{{ checksum "yarn.lock" }}
- chatwoot-yarn- - chatwoot-yarn-
- run: - run:
@ -65,7 +62,7 @@ jobs:
# Store yarn / webpacker cache # Store yarn / webpacker cache
- save_cache: - save_cache:
key: chatwoot-yarn-{{ .Environment.CACHE_VERSION }}-{{ checksum "yarn.lock" }} key: chatwoot-yarn-{{ checksum "yarn.lock" }}
paths: paths:
- ~/.cache/yarn - ~/.cache/yarn
@ -80,19 +77,6 @@ jobs:
paths: paths:
- cc-test-reporter - cc-test-reporter
# verify swagger specification
- run:
name: Verify swagger API specification
command: |
bundle exec rake swagger:build
if [[ `git status swagger/swagger.json --porcelain` ]]
then
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
exit 1
fi
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
# 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
@ -105,10 +89,6 @@ 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
@ -118,79 +98,34 @@ jobs:
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
mkdir -p ~/tmp/test-results/rspec bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10
mkdir -p ~/tmp/test-artifacts ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
mkdir -p coverage - persist_to_workspace:
~/tmp/cc-test-reporter before-build root: ~/tmp
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) paths:
bundle exec rspec --format progress \ - codeclimate.backend.json
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t simplecov -o "coverage/codeclimate.$CIRCLE_NODE_INDEX.json"
- run: - run:
name: Run frontend tests name: Run frontend tests
command: | command: |
mkdir -p ~/tmp/test-results/frontend_specs yarn test:coverage
~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter format-coverage -t lcov -o ~/tmp/codeclimate.frontend.json buildreports/lcov.info
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out ~/tmp/test-results/yarn.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
- persist_to_workspace: - persist_to_workspace:
root: coverage root: ~/tmp
paths: paths:
- codeclimate.*.json - codeclimate.frontend.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-artifacts path: ~/tmp/test-results
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 --output - codeclimate.*.json | ~/tmp/cc-test-reporter upload-coverage --debug --input - ~/tmp/cc-test-reporter sum-coverage ~/tmp/codeclimate.*.json -p 2 -o ~/tmp/codeclimate.total.json
~/tmp/cc-test-reporter upload-coverage -i ~/tmp/codeclimate.total.json
workflows:
version: 2
commit:
jobs:
- build
- upload-coverage:
requires:
- build

View file

@ -1,4 +1,4 @@
version: '2' version: "2"
plugins: plugins:
rubocop: rubocop:
enabled: false enabled: false
@ -14,45 +14,19 @@ plugins:
checks: checks:
similar-code: similar-code:
enabled: false enabled: false
method-count:
enabled: true
config:
threshold: 32
file-lines:
enabled: true
config:
threshold: 300
method-lines:
config:
threshold: 50
exclude_patterns: exclude_patterns:
- 'spec/' - "spec/"
- '**/specs/' - "**/specs/"
- 'db/*' - "db/*"
- 'bin/**/*' - "bin/**/*"
- 'db/**/*' - "db/**/*"
- 'config/**/*' - "config/**/*"
- 'public/**/*' - "public/**/*"
- 'vendor/**/*' - "vendor/**/*"
- 'node_modules/**/*' - "node_modules/**/*"
- 'lib/tasks/auto_annotate_models.rake' - "lib/tasks/auto_annotate_models.rake"
- 'app/test-matchers.js' - "app/test-matchers.js"
- 'docs/*' - "docs/*"
- '**/*.md' - "**/*.md"
- '**/*.yml' - "**/*.yml"
- 'app/javascript/dashboard/i18n/locale' - "app/javascript/dashboard/i18n/locale"
- '**/*.stories.js'
- 'stories/'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js'
- 'app/javascript/shared/constants/countries.js'
- 'app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js'
- 'app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js'
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'

View file

@ -1,8 +1,51 @@
# The below image is created out of the Dockerfile.base # pre-build stage
# It has the dependencies already installed so that codespace will boot up fast ARG VARIANT=2.7
FROM ghcr.io/chatwoot/chatwoot_codespace:latest FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
# [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
# tmux is for overmind
# TODO : install foreman in future
# packages: postgresql-server-dev-all
# may be postgres in same machine
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
libssl-dev \
tar \
tzdata \
postgresql-client \
yarn \
git \
imagemagick \
tmux \
zsh
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# 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
# TODO: figure out installing rvm
# RUN rvm install
COPY Gemfile Gemfile.lock ./
RUN gem install bundler
RUN bundle install
COPY package.json yarn.lock ./
RUN yarn install

View file

@ -1,72 +0,0 @@
ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/base:${VARIANT}
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
groupmod --gid $USER_GID vscode \
&& usermod --uid $USER_UID --gid $USER_GID vscode \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm
# Install rbenv and ruby
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
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& sudo mv overmind /usr/local/bin \
&& 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
WORKDIR /workspace
COPY . /workspace
# set up ruby
COPY Gemfile Gemfile.lock ./
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

@ -12,29 +12,22 @@
"extensions": [ "extensions": [
"rebornix.Ruby", "rebornix.Ruby",
"misogi.ruby-rubocop", "misogi.ruby-rubocop",
"wingrunr21.vscode-ruby", "wingrunr21.vscode-ruby"
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
], ],
// TODO: figure whether we can get all this ports work properly
// 3000 rails
// 3035 webpacker
// 5432 postgres // 5432 postgres
// 6379 redis // 6379 redis
// 1025,8025 mailhog // 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3035], "forwardPorts": [5432, 6379, 1025, 8025],
//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", // Use 'postCreateCommand' to run commands after the container is created.
"portsAttributes": { // #TODO: can we move logic of copy env file into dockerfile ?
"3000": { "postCreateCommand": "cp .env.example .env",
"label": "Rails Server"
},
"3035": {
"label": "Webpack Dev Server"
},
"8025": {
"label": "Mailhog UI"
}
}
} }

View file

@ -10,8 +10,8 @@ services:
context: .. context: ..
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
# Update 'VARIANT' to pick a Ruby version: https://github.com/microsoft/vscode-dev-containers/tree/main/containers/ruby # Update 'VARIANT' to pick a Ruby version: 2, 2.7, 2.6, 2.5
VARIANT: 3 VARIANT: 2.7
# [Choice] Install Node.js # [Choice] Install Node.js
INSTALL_NODE: "true" INSTALL_NODE: "true"
NODE_VERSION: "lts/*" NODE_VERSION: "lts/*"

View file

@ -1,13 +0,0 @@
cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME

View file

@ -7,8 +7,8 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
indent_style = space indent_style = spaces
tab_width = 2 tab_width = 2
[*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}] [{*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
indent_size = 2 indent_size = 2

View file

@ -3,8 +3,6 @@ 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
@ -34,20 +32,7 @@ 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=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# 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=
@ -58,12 +43,12 @@ 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
# Set the value to "mailhog" if using docker-compose for development environments, # the default value is set "mailhog" and is used by docker-compose for development environments,
# Set the value as "localhost" or your SMTP address in other environments # Set the value as "localhost" or your SMTP address in other environments
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix) SMTP_ADDRESS=mailhog
SMTP_ADDRESS=
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
@ -72,9 +57,6 @@ SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO=true SMTP_ENABLE_STARTTLS_AUTO=true
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html # Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
SMTP_OPENSSL_VERIFY_MODE=peer SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
@ -103,6 +85,9 @@ AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_REGION= AWS_REGION=
# Sentry
SENTRY_DSN=
# 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
@ -117,9 +102,6 @@ FB_VERIFY_TOKEN=
FB_APP_SECRET= FB_APP_SECRET=
FB_APP_ID= FB_APP_ID=
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
IG_VERIFY_TOKEN=
# Twitter # Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup # documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID= TWITTER_APP_ID=
@ -133,11 +115,8 @@ SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app ### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables ## Mobile app env variables
IOS_APP_ID=L7YLMN4634.com.chatwoot.app IOS_APP_ID=6C953F3RX2.com.chatwoot.app
ANDROID_BUNDLE_ID=com.chatwoot.app
# 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
### 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
@ -155,31 +134,7 @@ 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
## 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_DSN=
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
# SCOUT_NAME=YOURAPPNAME (Production)
# SCOUT_MONITOR=true
## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# 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
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# 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
@ -188,28 +143,6 @@ USE_INBOX_AVATAR_FOR_BOT=true
# maxmindb api key to use geoip2 service # maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY= # IP_LOOKUP_API_KEY=
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
## Development Only Config ## Development Only Config
# if you want to use letter_opener for local emails # if you want to use letter_opener for local emails
# LETTER_OPENER=true # LETTER_OPENER=true
# meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC=
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
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,10 +1,5 @@
module.exports = { module.exports = {
extends: [ extends: ['airbnb-base/legacy', 'prettier', 'plugin:vue/recommended'],
'airbnb-base/legacy',
'prettier',
'plugin:vue/recommended',
'plugin:storybook/recommended',
],
parserOptions: { parserOptions: {
parser: 'babel-eslint', parser: 'babel-eslint',
ecmaVersion: 2020, ecmaVersion: 2020,
@ -24,32 +19,17 @@ 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': [ 'vue/max-attributes-per-line': ['error', {
'error', 'singleline': 20,
{ 'multiline': {
singleline: 20, 'max': 1,
multiline: { 'allowFirstLine': false
max: 1,
allowFirstLine: false,
},
}, },
], }],
'vue/html-self-closing': [ 'vue/html-self-closing': 'off',
'error', "vue/no-v-html": 'off',
{ 'import/extensions': ['off']
html: {
void: 'always',
normal: 'always',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
'vue/no-v-html': 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error',
}, },
settings: { settings: {
'import/resolver': { 'import/resolver': {
@ -60,10 +40,12 @@ module.exports = {
}, },
env: { env: {
browser: true, browser: true,
jest: true,
node: true, node: true,
jest: true,
jasmine: true
}, },
globals: { globals: {
__WEBPACK_ENV__: true,
bus: true, bus: true,
}, },
}; };

View file

@ -6,7 +6,6 @@ labels: 'Bug'
assignees: '' assignees: ''
--- ---
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
@ -17,11 +16,11 @@ Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See the error 4. See error
**Expected behavior** **Expected behavior**
Share a clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
@ -29,50 +28,27 @@ If applicable, add screenshots to help explain your problem.
**Browser logs** **Browser logs**
Share the browser logs to debug the issue further. Share the browser logs to debug the issue further
**Server logs** **Server logs**
Share the server logs to debug the issue further. Share the server logs to debug the issue further
**Environment** **Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other). Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
- [ ] app.chatwoot.com (Chatwoot Cloud) **Desktop (please complete the following information):**
- [ ] Self-hosted - OS: [e.g. iOS]
- - [ ] Linux VM - Browser [e.g. chrome, safari]
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22] - Version [e.g. 22]
**Smartphone (please complete the following information)** (If applicable) **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6, Pixel7] - Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, firefox, safari] - Browser [e.g. stock browser, safari]
- Version [e.g. 22] - Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

View file

@ -2,7 +2,8 @@
## Description ## Description
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue) Fixes # (issue)
## Type of change ## Type of change
@ -11,18 +12,18 @@ Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update - [ ] This change requires a documentation update
## How Has This Been Tested? ## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
## Checklist: ## Checklist:
- [ ] My code follows the style guidelines of this project - [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code - [ ] I have performed a self-review of my own code
- [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have commented on my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation - [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings - [ ] My changes generate no new warnings

View file

@ -1,36 +0,0 @@
# 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.

View file

@ -1,46 +0,0 @@
# #
# #
# # 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

@ -1,23 +0,0 @@
name: Publish Codespace Base Image
on:
workflow_dispatch:
jobs:
publish-code-space-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build the Codespace Base Image
run: |
docker build . -t ghcr.io/chatwoot/chatwoot_codespace:latest -f .devcontainer/Dockerfile.base
docker push ghcr.io/chatwoot/chatwoot_codespace:latest

View file

@ -1,63 +0,0 @@
# #
# # 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
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}

View file

@ -1,73 +0,0 @@
# #
# # 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

7
.gitignore vendored
View file

@ -39,6 +39,9 @@ public/packs*
*.un~ *.un~
.jest-cache .jest-cache
#VS Code files
.vscode
# ignore jetbrains IDE files # ignore jetbrains IDE files
.idea .idea
@ -59,6 +62,4 @@ package-lock.json
test/cypress/videos/* test/cypress/videos/*
/config/master.key /config/master.key
/config/*.enc /config/*.enc
.vscode/settings.json

View file

@ -1,5 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run eslint
bundle exec rubocop -a
git add

View file

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
sh bin/validate_push

2
.nvmrc
View file

@ -1 +1 @@
14.17.4 12.16.1

View file

@ -1,6 +1,5 @@
{ {
"printWidth": 80, "printWidth": 80,
"singleQuote": true, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "es5"
"arrowParens": "avoid"
} }

View file

@ -11,13 +11,8 @@ Metrics/ClassLength:
Max: 125 Max: 125
Exclude: Exclude:
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb' - 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
@ -28,16 +23,13 @@ Style/FrozenStringLiteralComment:
Enabled: false Enabled: false
Style/SymbolArray: Style/SymbolArray:
Enabled: false Enabled: false
Style/OpenStructUse:
Enabled: false
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb' - 'app/dispatchers/dispatcher.rb'
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/01_redis.rb' - 'config/initializers/redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
- 'lib/global_config.rb' - 'lib/global_config.rb'
Style/ClassVars: Style/ClassVars:
@ -46,23 +38,12 @@ Style/ClassVars:
Lint/MissingSuper: Lint/MissingSuper:
Exclude: Exclude:
- 'app/drops/base_drop.rb' - 'app/drops/base_drop.rb'
Lint/SymbolConversion:
Enabled: false
Lint/EmptyBlock:
Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant:
Exclude:
- 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
- spec/**/* - spec/**/*
- '**/routes.rb' - '**/routes.rb'
- 'config/environments/*' - 'config/environments/*'
- db/schema.rb - db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@ -70,37 +51,18 @@ Rails/ApplicationController:
- 'app/controllers/widget_tests_controller.rb' - 'app/controllers/widget_tests_controller.rb'
- 'app/controllers/widgets_controller.rb' - 'app/controllers/widgets_controller.rb'
- 'app/controllers/platform_controller.rb' - 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Exclude: Exclude:
- 'config/application.rb' - 'config/application.rb'
Style/MapToHash:
Enabled: false
RSpec/NestedGroups: RSpec/NestedGroups:
Enabled: true Enabled: true
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
RSpec/StubbedMock:
Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber:
Enabled: false
Metrics/MethodLength: Metrics/MethodLength:
Exclude: Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Rails/CreateTableWithTimestamps: Rails/CreateTableWithTimestamps:
Exclude: Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb' - 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
@ -111,12 +73,10 @@ Style/GuardClause:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
Metrics/AbcSize: Metrics/AbcSize:
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.rb'
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb' - 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb' - 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 7 Max: 7
Exclude: Exclude:
@ -131,7 +91,6 @@ Rails/ReversibleMigration:
- 'db/migrate/20191020085608_rename_old_tables.rb' - 'db/migrate/20191020085608_rename_old_tables.rb'
- 'db/migrate/20191126185833_update_user_invite_foreign_key.rb' - 'db/migrate/20191126185833_update_user_invite_foreign_key.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb' - 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210513083044_remove_not_null_from_webhook_url_channel_api.rb'
Rails/BulkChangeTable: Rails/BulkChangeTable:
Exclude: Exclude:
- 'db/migrate/20161025070152_removechannelsfrommodels.rb' - 'db/migrate/20161025070152_removechannelsfrommodels.rb'
@ -142,25 +101,19 @@ Rails/BulkChangeTable:
- 'db/migrate/20170511134418_latlong.rb' - 'db/migrate/20170511134418_latlong.rb'
- 'db/migrate/20191027054756_create_contact_inboxes.rb' - 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb' - 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb' Rails/UniqueValidationWithoutIndex:
Rails/UniqueValidationWithoutIndex:
Exclude: Exclude:
- 'app/models/channel/twitter_profile.rb' - 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb' - 'app/models/webhook.rb'
- 'app/models/contact.rb' - 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 12 Max: 12
AllCops: AllCops:
NewCops: enable NewCops: enable
Exclude: Exclude:
@ -175,13 +128,4 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20200225162150_init_schema.rb' - 'db/migrate/20200225162150_init_schema.rb'
- 'db/migrate/20210611180222_create_active_storage_variant_records.active_storage.rb' - 'config/initializers/azure_storage_service_patch.rb'
- 'db/migrate/20210611180221_add_service_name_to_active_storage_blobs.active_storage.rb'
- db/migrate/20200309213132_add_account_id_to_agent_bot_inboxes.rb
- db/migrate/20200331095710_add_identifier_to_contact.rb
- db/migrate/20200429082655_add_medium_to_twilio_sms.rb
- db/migrate/20200503151130_add_account_feature_flag.rb
- db/migrate/20200927135222_add_last_activity_at_to_conversation.rb
- db/migrate/20210306170117_add_last_activity_at_to_contacts.rb
- db/migrate/20220809104508_revert_cascading_indexes.rb

View file

@ -1 +1 @@
3.0.4 2.7.2

View file

@ -1,44 +0,0 @@
const path = require('path');
const resolve = require('../config/webpack/resolve');
// Chatwoot's webpack.config.js
process.env.NODE_ENV = 'development';
const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{
name: '@storybook/addon-docs',
options: {
vueDocgenOptions: {
alias: {
'@': path.resolve(__dirname, '../'),
},
},
},
},
'@storybook/addon-links',
'@storybook/addon-essentials',
],
webpackFinal: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
modules: custom.resolvedModules.map(i => i.value),
},
};
newConfig.module.rules.push({
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
include: path.resolve(__dirname, '../app/javascript'),
});
return newConfig;
},
};

View file

@ -1,44 +0,0 @@
import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.component('multiselect', Multiselect);
Vue.component('fluent-icon', FluentIcon);
const store = new Vuex.Store({});
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function() {
this.$root._i18n = this.$i18n;
},
}));
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};

View file

@ -1,32 +0,0 @@
{
"recommendations": [
// Spell check
"streetsidesoftware.code-spell-checker",
// Better Comments
"aaron-bond.better-comments",
// Rails Test Runner
"davidpallinder.rails-test-runner",
// Eslint
"dbaeumer.vscode-eslint",
// Auto Close Tag
"formulahendry.auto-close-tag",
// Auto Rename Tag
"formulahendry.auto-rename-tag",
// Hight light colors
"naumovs.color-highlight",
// GitLens
"eamodio.gitlens",
// Ruby
"rebornix.ruby",
// Vue
"octref.vetur",
// Prettier
"esbenp.prettier-vscode",
// Dot Env
"mikestead.dotenv",
// HTML CSS Support
"ecmel.vscode-html-css",
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
]
}

View file

@ -1 +0,0 @@
{}

View file

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@chatwoot.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View file

@ -1,5 +0,0 @@
# Contributing to Chatwoot
Thanks for taking the time to contribute! :tada::+1:
Please refer to our [Contributing Guide](https://www.chatwoot.com/docs/contributing-guide) for detailed instructions on how to contribute.

82
Gemfile
View file

@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.0.4' ruby '2.7.2'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', require: 'rack/cors' gem 'rack-cors', require: 'rack/cors'
gem 'rails', '~> 6.1', '>= 6.1.6.1' gem 'rails'
# 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
@ -31,24 +31,19 @@ gem 'haikunator'
gem 'liquid' gem 'liquid'
# Parse Markdown to HTML # Parse Markdown to HTML
gem 'commonmarker' gem 'commonmarker'
# Validate Data against JSON Schema
gem 'json_schemer'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down', '~> 5.0'
##-- for active storage --## ##-- for active storage --##
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', '~> 1.12.2' gem 'mini_magick'
##-- gems for database --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'pg' gem 'pg'
gem 'redis' gem 'redis'
gem 'redis-namespace' gem 'redis-namespace'
gem 'redis-rack-cache'
# super fast record imports in bulk # super fast record imports in bulk
gem 'activerecord-import' gem 'activerecord-import'
@ -56,13 +51,12 @@ gem 'activerecord-import'
gem 'dotenv-rails' gem 'dotenv-rails'
gem 'foreman' gem 'foreman'
gem 'puma' gem 'puma'
gem 'webpacker', '~> 5.4', '>= 5.4.3' gem 'webpacker', '~> 5.x'
# metrics on heroku # metrics on heroku
gem 'barnes' gem 'barnes'
##--- gems for authentication & authorization ---## ##--- gems for authentication & authorization ---##
gem 'devise' gem 'devise'
gem 'devise-secure_password', '~> 2.0', git: 'https://github.com/chatwoot/devise-secure_password'
gem 'devise_token_auth' gem 'devise_token_auth'
# authorization # authorization
gem 'jwt' gem 'jwt'
@ -76,9 +70,9 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---## ##--- gems for channels ---##
# TODO: bump up gem to 2.0 # TODO: bump up gem to 2.0
gem 'facebook-messenger' gem 'facebook-messenger', '1.5.0'
gem 'line-bot-api' gem 'telegram-bot-ruby'
gem 'twilio-ruby', '~> 5.66' gem 'twilio-ruby', '~> 5.32.0'
# 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'
@ -86,22 +80,17 @@ gem 'twitty'
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client' gem 'slack-ruby-client'
# for dialogflow integrations
gem 'google-cloud-dialogflow'
##-- apm and error monitoring ---# ##--- gems for debugging and error reporting ---##
gem 'ddtrace' # static analysis
gem 'elastic-apm' gem 'brakeman'
gem 'newrelic_rpm'
gem 'scout_apm' gem 'scout_apm'
gem 'sentry-rails', '~> 5.3', '>= 5.3.1' gem 'sentry-raven'
gem 'sentry-ruby', '~> 5.3'
gem 'sentry-sidekiq', '~> 5.3'
##-- background job processing --## ##-- background job processing --##
gem 'sidekiq', '~> 6.4.0' gem 'sidekiq'
# We want cron jobs # We want cron jobs
gem 'sidekiq-cron', '~> 1.3' gem 'sidekiq-cron'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
@ -116,30 +105,6 @@ gem 'maxminddb'
# to create db triggers # to create db triggers
gem 'hairtrigger' gem 'hairtrigger'
gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
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
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
end
group :development do group :development do
gem 'annotate' gem 'annotate'
gem 'bullet' gem 'bullet'
@ -147,7 +112,7 @@ group :development do
gem 'web-console' gem 'web-console'
# used in swagger build # used in swagger build
gem 'json_refs' gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: 'e32deb0'
# When we want to squash migrations # When we want to squash migrations
gem 'squasher' gem 'squasher'
@ -158,31 +123,28 @@ group :test do
gem 'cypress-on-rails', '~> 1.0' gem 'cypress-on-rails', '~> 1.0'
# fast cleaning of database # fast cleaning of database
gem 'database_cleaner' gem 'database_cleaner'
# mock http calls
gem 'webmock'
end end
group :development, :test do group :development, :test do
gem 'active_record_query_trace' # locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved
##--- gems for debugging and error reporting ---## gem 'action-cable-testing'
# 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 'factory_bot_rails' gem 'factory_bot_rails'
gem 'faker'
gem 'listen' gem 'listen'
gem 'mock_redis' gem 'mock_redis', git: 'https://github.com/sds/mock_redis', ref: '16d00789f0341a3aac35126c0ffe97a596753ff9'
gem 'pry-rails' gem 'pry-rails'
gem 'rspec_junit_formatter' gem 'rspec-rails', '~> 4.0.0.beta2'
gem 'rspec-rails', '~> 5.0.3'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false
gem 'scss_lint', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false gem 'simplecov', '0.17.1', require: false
gem 'spring' gem 'spring'
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
gem 'webmock'
end end

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,7 @@
The MIT License (MIT)
Copyright (c) 2017-2021 Chatwoot Inc. Copyright (c) 2017-2021 Chatwoot Inc.
Portions of this software are licensed as follows:
* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE".
* All third party components incorporated into the Chatwoot Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights

View file

@ -6,82 +6,79 @@
<p align="center"> <p align="center">
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku"> <a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/> <img alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a> </a>
</p> </p>
___ ___
<p align="center"> <p align="center">
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a> <a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/80f9e1a7c72d186289ad/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>
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month"> <img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a> <a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a> <a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
<a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a> <a href="https://huntr.dev/bounties/disclose"><img src="https://cdn.huntr.dev/huntr_security_badge_mono.svg" alt="Huntr"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fuptime.json" alt="uptime"></a>
<a href="https://status.chatwoot.com"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchatwoot%2Fstatus%2Fmaster%2Fapi%2Fchatwoot%2Fresponse-time.json" alt="response time"></a>
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
</p> </p>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.png" width="100%" alt="Chat dashboard"/> <img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>
Chatwoot is an open-source omnichannel customer support software. The development of Chatwoot started in 2016. It failed to succeed as a business and eventually shut up shop in 2017. During 2019 #Hacktoberfest, the maintainers decided to make it open-source, instead of letting the code rust in a private repo. With a pleasant surprise, Chatwoot became a trending project on Hacker News and best of all, got lots of love from the community.
Now, a failed project is back on track and the prospects are looking great. The team is back to working on the project and this time, we are building it in the open. Thanks to the ideas and contributions from the community.
---
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile. ### Features
## Features Chatwoot gives an integrated view of conversations happening in different communication channels.
Chatwoot supports the following conversation channels: It supports the following conversation channels:
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support. - **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page. - **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned. - **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard. - **Whatsapp**: Connect your Whatsapp business account and manage the conversation in Chatwoot
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot. - **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel. - **API Channel**: Build custom communication channels using our API channel.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard. - **Email (beta)**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
And more.
Other features include: Other features include:
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes. - **Multi-brand inboxes**: Manage multiple brands or pages using a single dashboard.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow. - **Private notes**: Inter team communication is possible using private notes in a conversation.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions. - **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labels to create custom workflows. - **Conversation Labels**: Use conversation labelling to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load. - **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email. - **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot would send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages. - **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs. - **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard. - **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation ---
Detailed documentation is available at [chatwoot.com/help-center](https://www.chatwoot.com/help-center). ### Documentation
## Translation process Detailed documentation is available at [www.chatwoot.com/help-center](https://www.chatwoot.com/help-center).
### Translation process
The translation process for Chatwoot web and mobile app is managed at [https://translate.chatwoot.com](https://translate.chatwoot.com) using Crowdin. Please read the [translation guide](https://www.chatwoot.com/docs/contributing/translating-chatwoot-to-your-language) for contributing to Chatwoot. The translation process for Chatwoot web and mobile app is managed at [https://translate.chatwoot.com](https://translate.chatwoot.com) using Crowdin. Please read the [translation guide](https://www.chatwoot.com/docs/contributing/translating-chatwoot-to-your-language) for contributing to Chatwoot.
## Branching model ---
### Branching model
We use the [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`. We use the [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`.
If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`. If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`.
## Deployment ---
### Heroku one-click deploy ### Deployment
#### Heroku one-click deploy
Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button: Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button:
@ -89,34 +86,17 @@ Deploying Chatwoot to Heroku is a breeze. It's as simple as clicking this button
Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables. Follow this [link](https://www.chatwoot.com/docs/environment-variables) to understand setting the correct environment variables for the app to work with all the features. There might be breakages if you do not set the relevant environment variables.
#### Other deployment options
### DigitalOcean 1-Click Kubernetes deployment Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover.
Chatwoot now supports 1-Click deployment to DigitalOcean as a kubernetes app. ---
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean"> ### Contributors ✨
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
### Other deployment options
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
## Contributors ✨
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2022, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2021, Chatwoot Inc - Released under the MIT License.

View file

@ -1,56 +1,8 @@
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. # Security Policy
## 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). This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts. We use [huntr.dev](https://huntr.dev/) for security issues that affect our project. If you believe you have found a vulnerability, please disclose it via this [form](https://huntr.dev/bounties/disclose).
This will enable us to review the vulnerability, fix it promptly, and reward you for your efforts.
If you have any questions about the process, contact security@chatwoot.com. If you have any questions about the process, feel free to reach out to hello@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 | |
## 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
You can learn more about our triaging process [here](https://www.chatwoot.com/docs/contributing-guide/security-reports).
## Non-Qualifying Vulnerabilities
We consider the following out of scope, though there may be exceptions.
- Missing HTTP security headers
- Incomplete/Missing SPF/DKIM
- 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
- Denial of Service attacks
- Brute force attacks
- DNSSEC
If you are unsure about the scope, please create a [report](https://huntr.dev/repos/chatwoot/chatwoot/).
## Thanks
Thank you for keeping Chatwoot and our users safe. 🙇

View file

@ -1 +0,0 @@
2.2.0

View file

@ -1 +0,0 @@
2.1.0

View file

@ -28,37 +28,18 @@
"FRONTEND_URL": { "FRONTEND_URL": {
"description": "Public root URL of the Chatwoot installation. This will be used in the emails.", "description": "Public root URL of the Chatwoot installation. This will be used in the emails.",
"value": "https://CHANGE.herokuapp.com" "value": "https://CHANGE.herokuapp.com"
},
"INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.",
"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": {
"web": { "web": {
"quantity": 1, "quantity": 1
"size": "basic"
}, },
"worker": { "worker": {
"quantity": 1, "quantity": 1
"size": "basic"
} }
}, },
"stack": "heroku-20",
"image": "heroku/ruby", "image": "heroku/ruby",
"addons": [ "addons": [ "heroku-redis", "heroku-postgresql"],
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:mini"
}
],
"stack": "heroku-20",
"buildpacks": [ "buildpacks": [
{ {
"url": "heroku/ruby" "url": "heroku/ruby"

View file

@ -1,20 +1,10 @@
# 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!, { retain_original_contact_name: false, discard_invalid_attrs: false }] pattr_initialize [:contact!, :params!]
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 @contact = merge_contact(existing_identified_contact, @contact) if merge_contacts?(existing_identified_contact, @contact)
merge_if_existing_email_contact @contact = merge_contact(existing_email_contact, @contact) if merge_contacts?(existing_email_contact, @contact)
merge_if_existing_phone_number_contact
update_contact update_contact
end end
@contact @contact
@ -26,106 +16,35 @@ class ContactIdentifyAction
@account ||= @contact.account @account ||= @contact.account
end end
def merge_if_existing_identified_contact
return unless merge_contacts?(existing_identified_contact, :identifier)
process_contact_merge(existing_identified_contact)
end
def merge_if_existing_email_contact
return unless merge_contacts?(existing_email_contact, :email)
process_contact_merge(existing_email_contact)
end
def merge_if_existing_phone_number_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
def existing_identified_contact def existing_identified_contact
return if params[:identifier].blank? return if params[:identifier].blank?
@existing_identified_contact ||= account.contacts.find_by(identifier: params[:identifier]) @existing_identified_contact ||= Contact.where(account_id: account.id).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 ||= account.contacts.find_by(email: params[:email]) @existing_email_contact ||= Contact.where(account_id: account.id).find_by(email: params[:email])
end end
def existing_phone_number_contact def merge_contacts?(existing_contact, _contact)
return if params[:phone_number].blank? existing_contact && existing_contact.id != @contact.id
@existing_phone_number_contact ||= account.contacts.find_by(phone_number: params[:phone_number])
end
def merge_contacts?(existing_contact, key)
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| custom_attributes = params[:custom_attributes] ? @contact.custom_attributes.merge(params[:custom_attributes]) : @contact.custom_attributes
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.discard_invalid_attrs if discard_invalid_attrs @contact.update!(params.slice(:name, :email, :identifier).reject { |_k, v| v.blank? }.merge({ custom_attributes: custom_attributes }))
@contact.save! ContactAvatarJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end end
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,
mergee_contact: merge_contact mergee_contact: merge_contact
).perform ).perform
end end
def custom_attributes
return @contact.custom_attributes if params[:custom_attributes].blank?
(@contact.custom_attributes || {}).deep_merge(params[:custom_attributes].stringify_keys)
end
def additional_attributes
return @contact.additional_attributes if params[:additional_attributes].blank?
(@contact.additional_attributes || {}).deep_merge(params[:additional_attributes].stringify_keys)
end
end end

View file

@ -1,18 +1,13 @@
class ContactMergeAction class ContactMergeAction
include Events::Types
pattr_initialize [:account!, :base_contact!, :mergee_contact!] pattr_initialize [:account!, :base_contact!, :mergee_contact!]
def perform def perform
# This case happens when an agent updates a contact email in dashboard,
# while the contact also update his email via email collect box
return @base_contact if base_contact.id == mergee_contact.id
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
validate_contacts validate_contacts
merge_conversations merge_conversations
merge_messages merge_messages
merge_contact_inboxes merge_contact_inboxes
merge_and_remove_mergee_contact remove_mergee_contact
end end
@base_contact @base_contact
end end
@ -41,17 +36,7 @@ class ContactMergeAction
ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id) ContactInbox.where(contact_id: @mergee_contact.id).update(contact_id: @base_contact.id)
end end
def merge_and_remove_mergee_contact def remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank
# attributes in base contact are given preference
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
@mergee_contact.destroy! @mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
@base_contact.update!(merged_attributes)
end end
end end

View file

@ -43,7 +43,7 @@ $woot-logo-padding: $space-large $space-two;
// Colors // Colors
$color-woot: #1f93ff; $color-woot: #1f93ff;
$color-gray: #6e6f73; $color-gray: #6e6f73;
$color-light-gray: #747677; $color-light-gray: #999a9b;
$color-border: #e0e6ed; $color-border: #e0e6ed;
$color-border-light: #f0f4f5; $color-border-light: #f0f4f5;
$color-background: #f4f6fb; $color-background: #f4f6fb;

28
app/bot/facebook_bot.rb Normal file
View file

@ -0,0 +1,28 @@
require 'facebook/messenger'
class FacebookBot
include Facebook::Messenger
Bot.on :message do |message|
Rails.logger.info "MESSAGE_RECIEVED #{message}"
response = ::Integrations::Facebook::MessageParser.new(message)
::Integrations::Facebook::MessageCreator.new(response).perform
end
Bot.on :delivery do |delivery|
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38'
# delivery.sender # => { 'id' => '1008372609250235' }
# delivery.recipient # => { 'id' => '2015573629214912' }
# delivery.at # => 2016-04-22 21:30:36 +0200
# delivery.seq # => 37
updater = Integrations::Facebook::DeliveryStatus.new(delivery)
updater.perform
Rails.logger.info "Human was online at #{delivery.at}"
end
Bot.on :message_echo do |message|
Rails.logger.info "MESSAGE_ECHO #{message}"
response = ::Integrations::Facebook::MessageParser.new(message)
::Integrations::Facebook::MessageCreator.new(response).perform
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, :locale] pattr_initialize [:account_name!, :email!, :confirmed!, :user, :user_full_name, :user_password]
def perform def perform
if @user.nil? if @user.nil?
@ -61,11 +61,12 @@ class AccountBuilder
end end
def create_user def create_user
password = user_password || SecureRandom.alphanumeric(12)
@user = User.new(email: @email, @user = User.new(email: @email,
password: user_password, password: password,
password_confirmation: user_password, password_confirmation: password,
name: @user_full_name) name: @user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end

View file

@ -1,43 +0,0 @@
class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
ActiveRecord::Base.transaction do
@contact_inbox.lock!
# We won't send campaigns if a conversation is already present
raise 'Conversation alread present' if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
end
@conversation
rescue StandardError => e
Rails.logger.info(e.message)
nil
end
private
def message_params
ActionController::Parameters.new({
content: @campaign.message,
campaign_id: @campaign.id
})
end
def conversation_params
{
account_id: @campaign.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes,
custom_attributes: custom_attributes || {}
}
end
end

View file

@ -0,0 +1,61 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
build_contact_inbox
end
private
def account
@account ||= inbox.account
end
def create_contact_inbox(contact)
::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
end
def update_contact_avatar(contact)
::ContactAvatarJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
end
def create_contact
account.contacts.create!(
name: contact_attributes[:name],
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes]
)
end
def find_contact
contact = nil
contact = account.contacts.find_by(identifier: contact_attributes[:identifier]) if contact_attributes[:identifier].present?
contact ||= account.contacts.find_by(email: contact_attributes[:email]) if contact_attributes[:email].present?
contact ||= account.contacts.find_by(phone_number: contact_attributes[:phone_number]) if contact_attributes[:phone_number].present?
contact
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
contact_inbox = create_contact_inbox(contact)
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.info e
end
end
end

View file

@ -1,54 +1,27 @@
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder class ContactInboxBuilder
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }] pattr_initialize [:contact_id!, :inbox_id!, :source_id]
def perform def perform
@source_id ||= generate_source_id @contact = Contact.find(contact_id)
create_contact_inbox if source_id.present? @inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
end end
private private
def generate_source_id def generate_source_id
case @inbox.channel_type return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
when 'Channel::TwilioSms' return @contact.email if @inbox.channel_type == 'Channel::Email'
twilio_source_id return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id nil
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
end end
def twilio_source_id def twilio_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number return unless @contact.phone_number
case @inbox.channel.medium case @inbox.channel.medium
when 'sms' when 'sms'
@ -58,11 +31,11 @@ class ContactInboxBuilder
end end
end end
def create_contact_inbox def create_contact_inbox(source_id)
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( ::ContactInbox.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
) )
end end
end end

View file

@ -1,86 +0,0 @@
# This Builder will create a contact and contact inbox with specified attributes.
# If an existing identified contact exisits, it will be returned.
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
def perform
find_or_create_contact_and_contact_inbox
# in case of race conditions where contact is created by another thread
# we will try to find the contact and create a contact inbox
rescue ActiveRecord::RecordNotUnique
find_or_create_contact_and_contact_inbox
end
def find_or_create_contact_and_contact_inbox
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
return @contact_inbox if @contact_inbox
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
end
private
def build_contact_with_contact_inbox
@contact = find_contact || create_contact
@contact_inbox = create_contact_inbox
end
def account
@account ||= inbox.account
end
def create_contact_inbox
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: @source_id,
hmac_verified: hmac_verified
).perform
end
def update_contact_avatar(contact)
::Avatar::AvatarFromUrlJob.perform_later(contact, contact_attributes[:avatar_url]) if contact_attributes[:avatar_url]
end
def create_contact
account.contacts.create!(
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes],
custom_attributes: contact_attributes[:custom_attributes]
)
end
def find_contact
contact = find_contact_by_identifier(contact_attributes[:identifier])
contact ||= find_contact_by_email(contact_attributes[:email])
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
contact
end
def find_contact_by_identifier(identifier)
return if identifier.blank?
account.contacts.find_by(identifier: identifier)
end
def find_contact_by_email(email)
return if email.blank?
account.contacts.find_by(email: email.downcase)
end
def find_contact_by_phone_number(phone_number)
return if phone_number.blank?
account.contacts.find_by(phone_number: phone_number)
end
end

View file

@ -1,40 +0,0 @@
class ConversationBuilder
pattr_initialize [:params!, :contact_inbox!]
def perform
look_up_exising_conversation || create_new_conversation
end
private
def look_up_exising_conversation
return unless @contact_inbox.inbox.lock_to_single_conversation?
@contact_inbox.conversations.last
end
def create_new_conversation
::Conversation.create!(conversation_params)
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = { status: 'pending' } if status[:status] == 'bot'
{
account_id: @contact_inbox.inbox.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end
end

View file

@ -1,28 +0,0 @@
class CsatSurveys::ResponseBuilder
pattr_initialize [:message]
def perform
raise 'Invalid Message' unless message.input_csat?
conversation = message.conversation
rating = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'rating')
feedback_message = message.content_attributes.dig('submitted_values', 'csat_survey_response', 'feedback_message')
return if rating.blank?
process_csat_response(conversation, rating, feedback_message)
end
private
def process_csat_response(conversation, rating, feedback_message)
csat_survey_response = message.csat_survey_response || CsatSurveyResponse.new(
message_id: message.id, account_id: message.account_id, conversation_id: message.conversation_id,
contact_id: conversation.contact_id, assigned_agent: conversation.assignee
)
csat_survey_response.rating = rating
csat_survey_response.feedback_message = feedback_message
csat_survey_response.save!
csat_survey_response
end
end

View file

@ -4,62 +4,90 @@
# based on this we are showing "not sent from chatwoot" message in frontend # based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages. # Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder class Messages::Facebook::MessageBuilder
attr_reader :response attr_reader :response
def initialize(response, inbox, outgoing_echo: false) def initialize(response, inbox, outgoing_echo: false)
super()
@response = response @response = response
@inbox = inbox @inbox = inbox
@outgoing_echo = outgoing_echo @outgoing_echo = outgoing_echo
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id) @sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (@outgoing_echo ? :outgoing : :incoming) @message_type = (@outgoing_echo ? :outgoing : :incoming)
@attachments = (@response.attachments || [])
end end
def perform def perform
# This channel might require reauthorization, may be owner might have changed the fb password
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
build_contact_inbox build_contact
build_message build_message
end end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception Raven.capture_exception(e)
true true
end end
private private
def build_contact_inbox def contact
@contact_inbox = ::ContactInboxWithContactBuilder.new( @contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
source_id: @sender_id, end
inbox: @inbox,
contact_attributes: contact_params def build_contact
).perform return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end end
def build_message def build_message
@message = conversation.messages.create!(message_params) @message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
@attachments.each do |attachment| attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
process_attachment(attachment) attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end end
end end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding)
rescue *ExceptionList::URI_EXCEPTIONS => e
Rails.logger.info "invalid url #{file_url} : #{e.message}"
end
def conversation def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation @conversation ||= Conversation.find_by(conversation_params) || build_conversation
end end
def build_conversation def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge( Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id contact_inbox_id: @contact_inbox.id
)) ))
end end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment) def location_params(attachment)
lat = attachment['payload']['coordinates']['lat'] lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long'] long = attachment['payload']['coordinates']['long']
@ -82,7 +110,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
contact_id: @contact_inbox.contact_id contact_id: contact.id
} }
end end
@ -93,15 +121,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type, message_type: @message_type,
content: response.content, content: response.content,
source_id: response.identifier, source_id: response.identifier,
sender: @outgoing_echo ? nil : @contact_inbox.contact sender: @outgoing_echo ? nil : contact
}
end
def process_contact_params_result(result)
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
avatar_url: result['profile_pic']
} }
end end
@ -109,18 +129,14 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
begin begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {} result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
result = {}
# 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
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
rescue StandardError => e rescue StandardError => e
result = {} result = {}
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception Raven.capture_exception(e)
end end
process_contact_params_result(result) {
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
}
end end
end end

View file

@ -1,153 +0,0 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false)
super()
@messaging = messaging
@inbox = inbox
@outgoing_echo = outgoing_echo
end
def perform
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
true
end
private
def attachments
@messaging[:message][:attachments] || {}
end
def message_type
@outgoing_echo ? :outgoing : :incoming
end
def message_identifier
message[:mid]
end
def message_source_id
@outgoing_echo ? recipient_id : sender_id
end
def sender_id
@messaging[:sender][:id]
end
def recipient_id
@messaging[:recipient][:id]
end
def message
@messaging[:message]
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def message_content
@messaging[:message][:text]
end
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
attachments.each do |attachment|
process_attachment(attachment)
end
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact
}
end
def already_sent_from_chatwoot?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
# "entry": [
# {
# "id": "<IGID>",// ig id of the business
# "time": 1569262486134,
# "messaging": [
# {
# "sender": {
# "id": "<IGSID>"
# },
# "recipient": {
# "id": "<IGID>"
# },
# "timestamp": 1569262485349,
# "message": {
# "mid": "<MESSAGE_ID>",
# "text": "<MESSAGE_CONTENT>"
# }
# }
# ]
# }
# ],
# }
end

View file

@ -8,53 +8,28 @@ class Messages::MessageBuilder
@conversation = conversation @conversation = conversation
@user = user @user = user
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@items = params.to_unsafe_h&.dig(:content_attributes, :items) @items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
end end
def perform def perform
@message = @conversation.messages.build(message_params) @message = @conversation.messages.build(message_params)
process_attachments if @attachments.present?
process_emails @attachments.each do |uploaded_attachment|
@message.save! attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end
end
@message.save
@message @message
end end
private private
def process_attachments
return if @attachments.blank?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.build(
account_id: @message.account_id,
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails]
bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails]
@message.content_attributes[:cc_emails] = cc_emails
@message.content_attributes[:bcc_emails] = bcc_emails
end
def message_type def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes' raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@ -64,29 +39,7 @@ class Messages::MessageBuilder
end end
def sender def sender
message_type == 'outgoing' ? (message_sender || @user) : @conversation.contact message_type == 'outgoing' ? @user : @conversation.contact
end
def external_created_at
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
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
return if @params[:sender_type] != 'AgentBot'
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end end
def message_params def message_params
@ -101,6 +54,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(automation_rule_id).merge(campaign_id).merge(template_params) }
end end
end end

View file

@ -1,93 +0,0 @@
class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
attachment_file = Down.download(
file_url
)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
attachment.save!
end
def fetch_story_link(attachment)
message = attachment.message
result = get_story_object_from_source_id(message.source_id)
return if result.blank?
story_id = result['story']['mention']['id']
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
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.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
end
end

View file

@ -15,9 +15,6 @@ 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

@ -4,7 +4,7 @@ class NotificationSubscriptionBuilder
def perform def perform
# if multiple accounts were used to login in same browser # if multiple accounts were used to login in same browser
move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id move_subscription_to_user if identifier_subscription && identifier_subscription.user_id != user.id
identifier_subscription.blank? ? build_identifier_subscription : update_identifier_subscription build_identifier_subscription if identifier_subscription.blank?
identifier_subscription identifier_subscription
end end
@ -25,10 +25,6 @@ class NotificationSubscriptionBuilder
end end
def build_identifier_subscription def build_identifier_subscription
@identifier_subscription = user.notification_subscriptions.create!(params.merge(identifier: identifier)) user.notification_subscriptions.create(params.merge(identifier: identifier))
end
def update_identifier_subscription
identifier_subscription.update(params.merge(identifier: identifier))
end end
end end

View file

@ -1,17 +1,9 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 25
def initialize(account, params) def initialize(account, params)
@account = account @account = account
@params = params @params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end end
def timeseries def timeseries
@ -20,14 +12,8 @@ class V2::ReportBuilder
# For backward compatible with old report # For backward compatible with old report
def build def build
if %w[avg_first_response_time avg_resolution_time].include?(params[:metric]) timeseries.each_with_object([]) do |p, arr|
timeseries.each_with_object([]) do |p, arr| arr << { value: p[1], timestamp: p[0].to_time.to_i }
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
@ -42,66 +28,84 @@ class V2::ReportBuilder
} }
end end
def conversation_metrics
if params[:type].equal?(:account)
conversations
else
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
end
end
private private
def scope
return account if params[:type].match?('account')
return inbox if params[:type].match?('inbox')
return user if params[:type].match?('agent')
end
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.where(id: params[:id]).first
end end
def user def user
@user ||= account.users.find(params[:id]) @user ||= account.users.where(id: params[:id]).first
end end
def label def conversations_count
@label ||= account.labels.find(params[:id]) scope.conversations
.group_by_day(:created_at, range: range, default_value: 0)
.count
end end
def team # unscoped removes all scopes added to a model previously
@team ||= account.teams.find(params[:id]) def incoming_messages_count
scope.messages.unscoped.where(account_id: account.id).incoming
.group_by_day(:created_at, range: range, default_value: 0)
.count
end end
def get_grouped_values(object_scope) def outgoing_messages_count
@grouped_values = object_scope.group_by_period( scope.messages.unscoped.where(account_id: account.id).outgoing
params[:group_by] || DEFAULT_GROUP_BY, .group_by_day(:created_at, range: range, default_value: 0)
:created_at, .count
default_value: 0,
range: range,
permit: %w[day week month year],
time_zone: @timezone
)
end end
def agent_metrics def resolutions_count
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE) scope.conversations
account_users.each_with_object([]) do |account_user, arr| .resolved
@user = account_user.user .group_by_day(:created_at, range: range, default_value: 0)
arr << { .count
id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
}
end
end end
def conversations def avg_first_response_time
@open_conversations = scope.conversations.where(account_id: @account.id).open scope.events
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count .where(name: 'first_response')
metric = { .group_by_day(:created_at, range: range, default_value: 0)
open: @open_conversations.count, .average(:value)
unattended: @open_conversations.count - first_response_count end
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account) def avg_resolution_time
metric scope.events.where(name: 'conversation_resolved')
.group_by_day(:created_at, range: range, default_value: 0)
.average(:value)
end
def range
parse_date_time(params[:since])..parse_date_time(params[:until])
end
# Taking average of average is not too accurate
# https://en.wikipedia.org/wiki/Simpson's_paradox
# TODO: Will optimize this later
def avg_resolution_time_summary
return 0 if avg_resolution_time.values.empty?
(avg_resolution_time.values.sum / avg_resolution_time.values.length)
end
def avg_first_response_time_summary
return 0 if avg_first_response_time.values.empty?
(avg_first_response_time.values.sum / avg_first_response_time.values.length)
end
def parse_date_time(datetime)
return datetime if datetime.is_a?(DateTime)
return datetime.to_datetime if datetime.is_a?(Time) || datetime.is_a?(Date)
DateTime.strptime(datetime, '%s')
end end
end end

View file

@ -1,8 +1,5 @@
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
@ -18,8 +15,6 @@ 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 })
@ -31,22 +26,18 @@ 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
def current_user def current_user
@current_user ||= if params[:user_id].blank? @current_user ||= if params[:user_id].blank?
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact Contact.find_by!(pubsub_token: @pubsub_token)
else else
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id]) User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end end
end end
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

@ -1,5 +0,0 @@
class AndroidAppController < ApplicationController
def assetlinks
render layout: false
end
end

View file

@ -16,8 +16,4 @@ class Api::BaseController < ApplicationController
authorize(model) authorize(model)
end end
def check_admin_authorization?
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end

View file

@ -1,35 +0,0 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
def index
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end
def show; end
def create
@agent_bot = Current.account.agent_bots.create!(permitted_params)
end
def update
@agent_bot.update!(permitted_params)
end
def destroy
@agent_bot.destroy!
head :ok
end
private
def agent_bot
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id])
end
def permitted_params
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
end

View file

@ -2,7 +2,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index] before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization before_action :check_authorization
before_action :find_user, only: [:create] before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create]
before_action :create_user, only: [:create] before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create] before_action :save_account_user, only: [:create]
@ -10,16 +9,19 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents = agents @agents = agents
end end
def create; end def destroy
@agent.current_account_user.destroy
def update head :ok
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end end
def destroy def update
@agent.current_account_user.destroy! @agent.update!(agent_params.except(:role))
head :ok @agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role]
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent }
end
def create
render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user }
end end
private private
@ -36,42 +38,32 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@user = User.find_by(email: new_agent_params[:email]) @user = User.find_by(email: new_agent_params[:email])
end end
# 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
def create_user def create_user
return @user.send_confirmation_instructions if @user return 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
def save_account_user def save_account_user
AccountUser.create!({ AccountUser.create!(
account_id: Current.account.id, account_id: Current.account.id,
user_id: @user.id, user_id: @user.id,
inviter_id: current_user.id
}.merge({
role: new_agent_params[:role], role: new_agent_params[:role],
availability: new_agent_params[:availability], inviter_id: current_user.id
auto_offline: new_agent_params[:auto_offline] )
}.compact))
end end
def agent_params def agent_params
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) params.require(:agent).permit(:email, :name, :role)
end end
def new_agent_params def new_agent_params
# intial string ensures the password requirements are met time = Time.now.to_i
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" params.require(:agent).permit(:email, :name, :role)
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) .merge!(password: time, password_confirmation: time, inviter: current_user)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end end
def agents def agents
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] }) @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
end
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end end
end end

View file

@ -1,58 +0,0 @@
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
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
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

@ -1,24 +0,0 @@
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

@ -1,87 +0,0 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
def index
@automation_rules = Current.account.automation_rules
end
def create
@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
def show; end
def update
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
def destroy
@automation_rule.destroy!
head :ok
end
def clone
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
new_rule = automation_rule.dup
new_rule.save!
@automation_rule = new_rule
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
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
params.permit(
:name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
end

View file

@ -1,6 +1,32 @@
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

@ -1,26 +0,0 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create
if type_matches?
::BulkActionsJob.perform_later(
account: @current_account,
user: current_user,
params: permitted_params
)
head :ok
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def type_matches?
['Conversation'].include?(params[:type])
end
def permitted_params
params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
end

View file

@ -12,10 +12,9 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token page_access_token: page_access_token
) )
@facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: 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
ChatwootExceptionTracker.new(e).capture_exception Rails.logger.info e
end end
end end
@ -23,15 +22,6 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
end end
def set_instagram_id(page_access_token, facebook_channel)
fb_object = Koala::Facebook::API.new(page_access_token)
response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' })
return if response['instagram_business_account'].blank?
instagram_id = response['instagram_business_account']['id']
facebook_channel.update(instagram_id: instagram_id)
end
# get params[:inbox_id], current_account. params[:omniauth_token] # get params[:inbox_id], current_account. params[:omniauth_token]
def reauthorize_page def reauthorize_page
if @inbox&.facebook? if @inbox&.facebook?
@ -55,13 +45,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
def update_fb_page(fb_page_id, access_token) def update_fb_page(fb_page_id, access_token)
fb_page = get_fb_page(fb_page_id) fb_page = get_fb_page(fb_page_id)
ActiveRecord::Base.transaction do fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token)
fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token) fb_page&.reauthorized!
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
end end
def get_fb_page(fb_page_id) def get_fb_page(fb_page_id)
@ -74,23 +59,49 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end end
def long_lived_token(omniauth_token) def long_lived_token(omniauth_token)
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['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.error e Rails.logger.info e
end end
def mark_already_existing_facebook_pages(data) def mark_already_existing_facebook_pages(data)
return [] if data.empty? return [] if data.empty?
data.inject([]) do |result, page_detail| data.inject([]) do |result, page_detail|
page_detail[:exists] = Current.account.facebook_pages.exists?(page_id: page_detail['id']) page_detail[:exists] = Current.account.facebook_pages.exists?(page_id: page_detail['id']) ? true : false
result << page_detail result << page_detail
end end
end end
def set_avatar(facebook_inbox, page_id) def set_avatar(facebook_inbox, page_id)
avatar_url = "https://graph.facebook.com/#{page_id}/picture?type=large" uri = get_avatar_url(page_id)
Avatar::AvatarFromUrlJob.perform_later(facebook_inbox, avatar_url)
return unless uri
avatar_resource = LocalResource.new(uri)
facebook_inbox.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
rescue *ExceptionList::URI_EXCEPTIONS => e
Rails.logger.info "invalid url #{file_url} : #{e.message}"
end
def get_avatar_url(page_id)
begin
url = 'http://graph.facebook.com/' << page_id << '/picture?type=large'
uri = URI.parse(url)
tries = 3
begin
response = uri.open(redirect: false)
rescue OpenURI::HTTPRedirect => e
uri = e.uri # assigned from the "Location" response header
retry if (tries -= 1).positive?
raise
end
pic_url = response.base_uri.to_s
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
pic_url = nil
end
pic_url
end end
end end

View file

@ -1,34 +0,0 @@
class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
before_action :campaign, except: [:index, :create]
before_action :check_authorization
def index
@campaigns = Current.account.campaigns
end
def create
@campaign = Current.account.campaigns.create!(campaign_params)
end
def destroy
@campaign.destroy!
head :ok
end
def show; end
def update
@campaign.update!(campaign_params)
end
private
def campaign
@campaign ||= Current.account.campaigns.find_by(display_id: params[:id])
end
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
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
@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses def canned_responses
if params[:search] if params[:search]
Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%") Current.account.canned_responses.where('short_code ILIKE ?', "#{params[:search]}%")
else else
Current.account.canned_responses Current.account.canned_responses
end end

View file

@ -1,58 +0,0 @@
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
@current_locale = params[:locale]
@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

@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
authenticate_twilio authenticate_twilio
build_inbox build_inbox
setup_webhooks if @twilio_channel.sms? setup_webhooks if @twilio_channel.sms?
rescue Twilio::REST::TwilioError => e
render_could_not_create_error(e.message)
rescue StandardError => e rescue StandardError => e
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
end end
@ -27,8 +29,6 @@ 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
@ -40,11 +40,10 @@ 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
) )
@ -52,7 +51,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, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium :account_id, :phone_number, :account_sid, :auth_token, :name, :medium
) )
end end
end end

View file

@ -1,9 +0,0 @@
class Api::V1::Accounts::Contacts::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_contact
private
def ensure_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end

View file

@ -1,18 +1,19 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController
before_action :ensure_contact
before_action :ensure_inbox, only: [:create] before_action :ensure_inbox, only: [:create]
def create def create
@contact_inbox = ContactInboxBuilder.new( source_id = params[:source_id] || SecureRandom.uuid
contact: @contact, @contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
inbox: @inbox,
source_id: params[:source_id]
).perform
end end
private private
def ensure_inbox def ensure_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id]) @inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show? end
def ensure_contact
@contact = Current.account.contacts.find(params[:contact_id])
end end
end end

View file

@ -1,17 +1,23 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::BaseController
def index def index
@conversations = Current.account.conversations.includes( @conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings :assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(id: :desc).limit(20) ).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
end end
private private
def inbox_ids def inbox_ids
if Current.user.administrator? || Current.user.agent? if Current.user.administrator?
Current.account.inboxes.pluck(:id)
elsif Current.user.agent?
Current.user.assigned_inboxes.pluck(:id) Current.user.assigned_inboxes.pluck(:id)
else else
[] []
end end
end end
def permitted_params
params.permit(:contact_id)
end
end end

View file

@ -1,13 +1,13 @@
class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::Contacts::BaseController class Api::V1::Accounts::Contacts::LabelsController < Api::V1::Accounts::BaseController
include LabelConcern include LabelConcern
private private
def model def model
@model ||= @contact @model ||= Current.account.contacts.find(permitted_params[:contact_id])
end end
def permitted_params def permitted_params
params.permit(labels: []) params.permit(:contact_id, labels: [])
end end
end end

View file

@ -1,32 +0,0 @@
class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts::BaseController
before_action :note, except: [:index, :create]
def index
@notes = @contact.notes.latest.includes(:user)
end
def create
@note = @contact.notes.create!(note_params)
end
def destroy
@note.destroy!
head :ok
end
def show; end
def update
@note.update(note_params)
end
private
def note
@note ||= @contact.notes.find(params[:id])
end
def note_params
params.require(:note).permit(:content).merge({ contact_id: @contact.id, user_id: Current.user.id })
end
end

View file

@ -1,44 +1,32 @@
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
include Sift
sort_on :email, type: :string
sort_on :name, internal_name: :order_on_name, type: :scope, scope_params: [:direction]
sort_on :phone_number, type: :string
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
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]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@contacts = fetch_contacts_with_conversation_count(resolved_contacts) @contacts = fetch_contact_last_seen_at(resolved_contacts)
end end
def search def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = 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',
search: "%#{params[:q].strip}%" search: "%#{params[:q]}%"
) )
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contacts_with_conversation_count(contacts) @contacts = fetch_contact_last_seen_at(contacts)
end end
def import def import
render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank?
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
import = Current.account.data_imports.create!(data_type: 'contacts') import = Current.account.data_imports.create!(data_type: 'contacts')
import.import_file.attach(params[:import_file]) import.import_file.attach(params[:import_file])
end end
head :ok head :ok
end end
@ -52,127 +40,73 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def filter
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts_with_conversation_count(contacts)
end
def contactable_inboxes def contactable_inboxes
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get @contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
end
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
def destroy_custom_attributes
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
@contact.save!
end end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url)) @contact = Current.account.contacts.new(contact_params)
@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? rescue ActiveRecord::RecordInvalid => e
end render json: {
message: e.record.errors.full_messages.join(', '),
def destroy contact: Current.account.contacts.find_by(email: contact_params[:email])
if ::OnlineStatusTracker.get_presence( }, status: :unprocessable_entity
@contact.account.id, 'Contact', @contact.id
)
return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) },
:unprocessable_entity)
end
@contact.destroy!
head :ok
end
def avatar
@contact.avatar.purge if @contact.avatar.attached?
@contact
end end
private private
# TODO: Move this to a finder class
def resolved_contacts def resolved_contacts
return @resolved_contacts if @resolved_contacts @resolved_contacts ||= Current.account.contacts
.where.not(email: [nil, ''])
@resolved_contacts = Current.account.contacts.resolved_contacts .or(Current.account.contacts.where.not(phone_number: [nil, '']))
.order('LOWER(name)')
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts
end end
def set_current_page def set_current_page
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
def fetch_contacts_with_conversation_count(contacts) def fetch_contact_last_seen_at(contacts)
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations) contacts.left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count') .select('contacts.*, COUNT(conversations.id) as conversations_count, MAX(conversations.contact_last_seen_at) as last_seen_at')
.group('contacts.id') .group('contacts.id')
.includes([{ avatar_attachment: [:blob] }]) .includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }])
.page(@current_page).per(RESULTS_PER_PAGE) .page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_conversation_count
end end
def build_contact_inbox def build_contact_inbox
return if params[:inbox_id].blank? return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id]) inbox = Current.account.inboxes.find(params[:inbox_id])
ContactInboxBuilder.new( source_id = params[:source_id] || SecureRandom.uuid
contact: @contact, ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
inbox: inbox,
source_id: params[:source_id]
).perform
end end
def permitted_params def contact_params
params.permit(:name, :identifier, :email, :phone_number, :avatar, :avatar_url, additional_attributes: {}, custom_attributes: {}) params.require(:contact).permit(:name, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
end end
def contact_custom_attributes def contact_custom_attributes
return @contact.custom_attributes.merge(permitted_params[:custom_attributes]) if permitted_params[:custom_attributes] return @contact.custom_attributes.merge(contact_params[:custom_attributes]) if contact_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
permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes }) contact_params.except(:custom_attributes).merge({ custom_attributes: contact_custom_attributes })
end
def set_include_contact_inboxes
@include_contact_inboxes = if params[:include_contact_inboxes].present?
params[:include_contact_inboxes] == 'true'
else
true
end
end end
def fetch_contact def fetch_contact
@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)
render json: error, status: error_status
end
end end

View file

@ -1,34 +1,20 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation # assigns agent/team to a conversation
def create def create
if params.key?(:assignee_id) set_assignee
set_agent render json: @assignee
elsif params.key?(:team_id)
set_team
else
render json: nil
end
end end
private private
def set_agent def set_assignee
@agent = Current.account.users.find_by(id: params[:assignee_id]) # if params[:assignee_id] is not a valid id, it will set to nil, hence unassigning the conversation
@conversation.update_assignee(@agent) if params.key?(:assignee_id)
render_agent @assignee = Current.account.users.find_by(id: params[:assignee_id])
end @conversation.update_assignee(@assignee)
elsif params.key?(:team_id)
def render_agent @assignee = Current.account.teams.find_by(id: params[:team_id])
if @agent.nil? @conversation.update!(team: @assignee)
render json: nil
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
end end
end end
def set_team
@team = Current.account.teams.find_by(id: params[:team_id])
@conversation.update!(team: @team)
render json: @team
end
end end

View file

@ -1,11 +1,9 @@
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?
end end
end end

View file

@ -1,17 +0,0 @@
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'), content_attributes: { deleted: true }) message.update!(content: I18n.t('conversations.messages.deleted'), deleted: true)
message.attachments.destroy_all message.attachments.destroy_all
end end
end end

View file

@ -1,9 +1,8 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter] before_action :conversation, except: [:index]
before_action :inbox, :contact, :contact_inbox, only: [:create] before_action :contact_inbox, only: [:create]
def index def index
result = conversation_finder.perform result = conversation_finder.perform
@ -24,19 +23,13 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform @conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present? Messages::MessageBuilder.new(Current.user, @conversation, params[:message]).perform if params[:message].present?
end end
end end
def show; end def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
end
def mute def mute
@conversation.mute! @conversation.mute!
head :ok head :ok
@ -48,117 +41,73 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def transcript def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? ConversationReplyMailer.conversation_transcript(@conversation, params[:email])&.deliver_later if params[:email].present?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
head :ok head :ok
end end
def toggle_status def toggle_status
if params[:status] if params[:status]
set_conversation_status @conversation.status = params[:status]
@status = @conversation.save! @status = @conversation.save
else else
@status = @conversation.toggle_status @status = @conversation.toggle_status
end end
assign_conversation if @conversation.status == 'open' && Current.user.is_a?(User) && Current.user&.agent?
end end
def toggle_typing_status def toggle_typing_status
case params[:typing_status] case params[:typing_status]
when 'on' when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private]) trigger_typing_event(CONVERSATION_TYPING_ON)
when 'off' when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private]) trigger_typing_event(CONVERSATION_TYPING_OFF)
end end
head :ok head :ok
end end
def update_last_seen def update_last_seen
update_last_seen_on_conversation(DateTime.now.utc, assignee?) @conversation.agent_last_seen_at = DateTime.now.utc
end
def unread
last_incoming_message = @conversation.messages.incoming.last
last_seen_at = last_incoming_message.created_at - 1.second if last_incoming_message.present?
update_last_seen_on_conversation(last_seen_at, true)
end
def custom_attributes
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
@conversation.save! @conversation.save!
end end
private private
def update_last_seen_on_conversation(last_seen_at, update_assignee) def trigger_typing_event(event)
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def assign_conversation
@agent = Current.account.users.find(current_user.id)
@conversation.update_assignee(@agent)
end
def trigger_typing_event(event, is_private)
user = current_user.presence || @resource user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
end end
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) @conversation ||= Current.account.conversations.find_by(display_id: params[:id])
authorize @conversation.inbox, :show?
end
def inbox
return if params[:inbox_id].blank?
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def contact
return if params[:contact_id].blank?
@contact = Current.account.contacts.find(params[:contact_id])
end end
def contact_inbox def contact_inbox
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id]) @contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end end
def build_contact_inbox def build_contact_inbox
return if @inbox.blank? || @contact.blank? return if params[:contact_id].blank? || params[:inbox_id].blank?
ContactInboxBuilder.new( ContactInboxBuilder.new(
contact: @contact, contact_id: params[:contact_id],
inbox: @inbox, inbox_id: params[:inbox_id],
source_id: params[:source_id] source_id: params[:source_id]
).perform ).perform
end end
def conversation_finder def conversation_params
@conversation_finder ||= ConversationFinder.new(Current.user, params) additional_attributes = params[:additional_attributes]&.permit! || {}
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes
}
end end
def assignee? def conversation_finder
@conversation.assignee_id? && Current.user == @conversation.assignee @conversation_finder ||= ConversationFinder.new(current_user, params)
end end
end end

View file

@ -1,49 +0,0 @@
class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::BaseController
include Sift
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_authorization
before_action :set_csat_survey_responses, only: [:index, :metrics, :download]
before_action :set_current_page, only: [:index]
before_action :set_current_page_surveys, only: [:index]
before_action :set_total_sent_messages_count, only: [:metrics]
sort_on :created_at, type: :datetime
def index; end
def metrics
@total_count = @csat_survey_responses.count
@ratings_count = @csat_survey_responses.group(:rating).count
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', formats: [:csv]
end
private
def set_total_sent_messages_count
@csat_messages = Current.account.messages.input_csat
@csat_messages = @csat_messages.where(created_at: range) if range.present?
@total_sent_messages_count = @csat_messages.count
end
def set_csat_survey_responses
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
end
def set_current_page_surveys
@csat_survey_responses = @csat_survey_responses.page(@current_page).per(RESULTS_PER_PAGE)
end
def set_current_page
@current_page = params[:page] || 1
end
end

View file

@ -1,49 +0,0 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
def show; end
def create
@custom_attribute_definition = Current.account.custom_attribute_definitions.create!(
permitted_payload
)
end
def update
@custom_attribute_definition.update!(permitted_payload)
end
def destroy
@custom_attribute_definition.destroy!
head :no_content
end
private
def fetch_custom_attributes_definitions
@custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
end
def fetch_custom_attribute_definition
@custom_attribute_definition = Current.account.custom_attribute_definitions.find(permitted_params[:id])
end
def permitted_payload
params.require(:custom_attribute_definition).permit(
:attribute_display_name,
:attribute_description,
:attribute_display_type,
:attribute_key,
:attribute_model,
attribute_values: []
)
end
def permitted_params
params.permit(:id, :filter_type, :attribute_model)
end
end

View file

@ -1,49 +0,0 @@
class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseController
before_action :fetch_custom_filters, except: [:create]
before_action :fetch_custom_filter, only: [:show, :update, :destroy]
DEFAULT_FILTER_TYPE = 'conversation'.freeze
def index; end
def show; end
def create
@custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id)
)
end
def update
@custom_filter.update!(permitted_payload)
end
def destroy
@custom_filter.destroy!
head :no_content
end
private
def fetch_custom_filters
@custom_filters = current_user.custom_filters.where(
account_id: Current.account.id,
filter_type: permitted_params[:filter_type] || DEFAULT_FILTER_TYPE
)
end
def fetch_custom_filter
@custom_filter = @custom_filters.find(permitted_params[:id])
end
def permitted_payload
params.require(:custom_filter).permit(
:name,
:filter_type,
query: {}
)
end
def permitted_params
params.permit(:id, :filter_type)
end
end

View file

@ -1,44 +0,0 @@
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

@ -0,0 +1,55 @@
class Api::V1::Accounts::FacebookIndicatorsController < Api::V1::Accounts::BaseController
before_action :set_access_token
around_action :handle_with_exception
def mark_seen
fb_bot.deliver(payload('mark_seen'), access_token: @access_token)
head :ok
end
def typing_on
fb_bot.deliver(payload('typing_on'), access_token: @access_token)
head :ok
end
def typing_off
fb_bot.deliver(payload('typing_off'), access_token: @access_token)
head :ok
end
private
def fb_bot
::Facebook::Messenger::Bot
end
def handle_with_exception
yield
rescue Facebook::Messenger::Error => e
Rails.logger.debug "Rescued: #{e.inspect}"
true
end
def payload(action)
{
recipient: { id: contact.source_id },
sender_action: action
}
end
def inbox
@inbox ||= Current.account.inboxes.find(permitted_params[:inbox_id])
end
def set_access_token
@access_token = inbox.channel.page_access_token
end
def contact
@contact ||= inbox.contact_inboxes.find_by!(contact_id: permitted_params[:contact_id])
end
def permitted_params
params.permit(:inbox_id, :contact_id)
end
end

View file

@ -1,40 +1,22 @@
class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController
before_action :fetch_inbox before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create, :update] before_action :current_agents_ids, only: [:create]
def create def create
authorize @inbox, :create? # update also done via same action
ActiveRecord::Base.transaction do update_agents_list
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) } head :ok
end rescue StandardError => e
fetch_updated_agents Rails.logger.debug "Rescued: #{e.inspect}"
render_could_not_create_error('Could not add agents to inbox')
end end
def show def show
authorize @inbox, :show? @agents = Current.account.users.where(id: @inbox.members.select(:user_id))
fetch_updated_agents
end
def update
authorize @inbox, :update?
update_agents_list
fetch_updated_agents
end
def destroy
authorize @inbox, :destroy?
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @inbox.remove_member(user_id) }
end
head :ok
end end
private private
def fetch_updated_agents
@agents = Current.account.users.where(id: @inbox.members.select(:user_id))
end
def update_agents_list def update_agents_list
# get all the user_ids which the inbox currently has as members. # get all the user_ids which the inbox currently has as members.
# get the list of user_ids from params # get the list of user_ids from params

View file

@ -1,73 +1,37 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create] before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :check_authorization
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end 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
def campaigns
@campaigns = @inbox.campaigns
end
def avatar
@inbox.avatar.attachment.destroy! if @inbox.avatar.attached?
head :ok
end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel = create_channel channel = create_channel
@inbox = Current.account.inboxes.build( @inbox = Current.account.inboxes.build(
{ name: permitted_params[:name],
name: inbox_name(channel), greeting_message: permitted_params[:greeting_message],
channel: channel greeting_enabled: permitted_params[:greeting_enabled],
}.merge( channel: channel
permitted_params.except(:channel)
)
) )
@inbox.avatar.attach(permitted_params[:avatar])
@inbox.save! @inbox.save!
end end
end end
def update def update
@inbox.update!(permitted_params.except(:channel)) @inbox.update(inbox_update_params.except(:channel))
update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
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])
update_channel_feature_flags
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end return unless @inbox.channel.is_a?(Channel::WebWidget) && inbox_update_params[:channel].present?
def agent_bot @inbox.channel.update!(inbox_update_params[:channel])
@agent_bot = @inbox.agent_bot update_channel_feature_flags
end end
def set_agent_bot def set_agent_bot
@ -82,7 +46,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
@ -90,7 +54,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def fetch_inbox def fetch_inbox
@inbox = Current.account.inboxes.find(params[:id]) @inbox = Current.account.inboxes.find(params[:id])
authorize @inbox, :show?
end end
def fetch_agent_bot def fetch_agent_bot
@ -98,51 +61,42 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def create_channel def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) case permitted_params[:channel][:type]
when 'web_widget'
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) Current.account.web_widgets.create!(permitted_params[:channel].except(:type))
when 'api'
Current.account.api_channels.create!(permitted_params[:channel].except(:type))
when 'email'
Current.account.email_channels.create!(permitted_params[:channel].except(:type))
end
end end
def update_channel_feature_flags def update_channel_feature_flags
return unless @inbox.web_widget? return unless inbox_update_params[:channel].key? :selected_feature_flags
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags] @inbox.channel.selected_feature_flags = inbox_update_params[:channel][:selected_feature_flags]
@inbox.channel.save! @inbox.channel.save!
end end
def inbox_attributes def permitted_params
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel:
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email, :reply_time])
:lock_to_single_conversation]
end end
def permitted_params(channel_attributes = []) def inbox_update_params
params.permit( params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled,
*inbox_attributes, :working_hours_enabled, :out_of_office_message, :timezone,
channel: [:type, *channel_attributes] channel: [
) :website_url,
end :widget_color,
:welcome_title,
def channel_type_from_params :welcome_tagline,
{ :webhook_url,
'web_widget' => Channel::WebWidget, :email,
'api' => Channel::Api, :reply_time,
'email' => Channel::Email, :pre_chat_form_enabled,
'line' => Channel::Line, { pre_chat_form_options: [:pre_chat_message, :require_email] },
'telegram' => Channel::Telegram, { selected_feature_flags: [] }
'whatsapp' => Channel::Whatsapp, ])
'sms' => Channel::Sms
}[permitted_params[:channel][:type]]
end
def get_channel_attributes(channel_type)
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end
end end
end end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View file

@ -1,5 +1,4 @@
class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_apps, only: [:index] before_action :fetch_apps, only: [:index]
before_action :fetch_app, only: [:show] before_action :fetch_app, only: [:show]

View file

@ -1,31 +0,0 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:update, :destroy]
before_action :check_authorization
def create
@hook = Current.account.hooks.create!(permitted_params)
end
def update
@hook.update!(permitted_params.slice(:status, :settings))
end
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end
def check_authorization
authorize(:hook)
end
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
end

View file

@ -1,5 +1,4 @@
class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook, only: [:update, :destroy] before_action :fetch_hook, only: [:update, :destroy]
def create def create
@ -20,7 +19,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

@ -0,0 +1,9 @@
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

@ -0,0 +1,32 @@
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

@ -0,0 +1,32 @@
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

@ -1,89 +0,0 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, 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!
process_attachments
@macro
end
def show
head :not_found if @macro.nil?
end
def destroy
@macro.destroy!
head :ok
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
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@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
private
def process_attachments
actions = @macro.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)
@macro.files.attach(blob)
end
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
def check_authorization
authorize(@macro) if @macro.present?
end
end

View file

@ -1,6 +1,7 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
before_action :fetch_notification, only: [:update] before_action :fetch_notification, only: [:update]
before_action :set_primary_actor, only: [:read_all] before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]

View file

@ -1,83 +0,0 @@
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
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
end
def create
@portal = Current.account.portals.build(portal_params)
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo
end
def update
ActiveRecord::Base.transaction do
@portal.update!(portal_params) if params[:portal].present?
# @portal.custom_domain = parsed_custom_domain
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
def parsed_custom_domain
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
end

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