Merge branch 'master' into feat/reorder-sidebar

This commit is contained in:
Pranav Raj S 2022-01-12 13:57:58 -08:00
commit 02c66e5c1d
1460 changed files with 55576 additions and 11284 deletions

View file

@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.0.2-node
- image: cimg/ruby:3.0.2-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
@ -77,6 +77,18 @@ jobs:
paths:
- 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
- run: yarn install --check-files
- run: bundle exec rake db:create

View file

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

View file

@ -41,7 +41,7 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
# 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
@ -57,6 +57,9 @@ SMTP_AUTHENTICATION=
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
SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
@ -100,6 +103,9 @@ FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
IG_VERIFY_TOKEN=
# Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID=
@ -113,7 +119,7 @@ SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
IOS_APP_ID=L7YLMN4634.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)
@ -166,7 +172,7 @@ USE_INBOX_AVATAR_FOR_BOT=true
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=false
# ENABLE_RACK_ATTACK=true
## Running chatwoot as an API only server

View file

@ -28,6 +28,7 @@ module.exports = {
}],
'vue/html-self-closing': 'off',
"vue/no-v-html": 'off',
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off']
},

5
.husky/pre-commit Executable file
View file

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

4
.husky/pre-push Executable file
View file

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

View file

@ -11,9 +11,11 @@ Metrics/ClassLength:
Max: 125
Exclude:
- 'app/models/conversation.rb'
- 'app/models/contact.rb'
- 'app/mailers/conversation_reply_mailer.rb'
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
@ -56,7 +58,7 @@ Metrics/BlockLength:
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/woot_message_seeder.rb
- lib/seeders/message_seeder.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@ -86,6 +88,7 @@ Naming/VariableNumber:
Metrics/MethodLength:
Exclude:
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Rails/CreateTableWithTimestamps:
Exclude:
- 'db/migrate/20170207092002_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb'
@ -101,6 +104,7 @@ Metrics/AbcSize:
- 'db/migrate/20190819005836_add_missing_indexes_on_taggings.acts_as_taggable_on_engine.rb'
- 'db/migrate/20161123131628_devise_token_auth_create_users.rb'
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'db/migrate/20211219031453_update_foreign_keys_on_delete.rb'
Metrics/CyclomaticComplexity:
Max: 7
Exclude:

128
CODE_OF_CONDUCT.md Normal file
View file

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

5
CONTRIBUTING.md Normal file
View file

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

13
Gemfile
View file

@ -121,6 +121,10 @@ gem 'hairtrigger'
gem 'procore-sift'
# parse email
gem 'email_reply_trimmer'
gem 'html2text'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
@ -144,12 +148,20 @@ group :test do
gem 'cypress-on-rails', '~> 1.0'
# fast cleaning of database
gem 'database_cleaner'
# mock http calls
gem 'webmock'
end
group :development, :test do
# TODO: is this needed ?
# errors thrown by devise password gem
gem 'flay'
gem 'rspec'
# for error thrown by devise password gem
gem 'active_record_query_trace'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'factory_bot_rails'
gem 'faker'
gem 'listen'
@ -165,5 +177,4 @@ group :development, :test do
gem 'simplecov', '0.17.1', require: false
gem 'spring'
gem 'spring-watcher-listen'
gem 'webmock'
end

View file

@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
actioncable (6.1.4.3)
actionpack (= 6.1.4.3)
activesupport (= 6.1.4.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailbox (6.1.4.3)
actionpack (= 6.1.4.3)
activejob (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
mail (>= 2.7.1)
actionmailer (6.1.4.1)
actionpack (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionmailer (6.1.4.3)
actionpack (= 6.1.4.3)
actionview (= 6.1.4.3)
activejob (= 6.1.4.3)
activesupport (= 6.1.4.3)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.1)
actionview (= 6.1.4.1)
activesupport (= 6.1.4.1)
actionpack (6.1.4.3)
actionview (= 6.1.4.3)
activesupport (= 6.1.4.3)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.1)
actionpack (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
actiontext (6.1.4.3)
actionpack (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
nokogiri (>= 1.8.5)
actionview (6.1.4.1)
activesupport (= 6.1.4.1)
actionview (6.1.4.3)
activesupport (= 6.1.4.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
activejob (6.1.4.3)
activesupport (= 6.1.4.3)
globalid (>= 0.3.6)
activemodel (6.1.4.1)
activesupport (= 6.1.4.1)
activerecord (6.1.4.1)
activemodel (= 6.1.4.1)
activesupport (= 6.1.4.1)
activemodel (6.1.4.3)
activesupport (= 6.1.4.3)
activerecord (6.1.4.3)
activemodel (= 6.1.4.3)
activesupport (= 6.1.4.3)
activerecord-import (1.2.0)
activerecord (>= 3.2)
activestorage (6.1.4.1)
actionpack (= 6.1.4.1)
activejob (= 6.1.4.1)
activerecord (= 6.1.4.1)
activesupport (= 6.1.4.1)
activestorage (6.1.4.3)
actionpack (= 6.1.4.3)
activejob (= 6.1.4.3)
activerecord (= 6.1.4.3)
activesupport (= 6.1.4.3)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.1)
activesupport (6.1.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -90,21 +90,21 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.2)
attr_extras (6.2.4)
aws-eventstream (1.1.1)
aws-partitions (1.482.0)
aws-sdk-core (3.119.0)
aws-eventstream (1.2.0)
aws-partitions (1.513.0)
aws-sdk-core (3.121.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.46.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-kms (1.49.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.98.0)
aws-sdk-core (~> 3, >= 3.119.0)
aws-sdk-s3 (1.103.0)
aws-sdk-core (~> 3, >= 3.120.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.4)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.4.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.1)
azure-storage-common (~> 2.0)
@ -119,28 +119,29 @@ GEM
statsd-ruby (~> 1.1)
bcrypt (3.1.16)
bindex (0.8.1)
bootsnap (1.7.7)
bootsnap (1.9.1)
msgpack (~> 1.0)
brakeman (5.1.1)
browser (5.3.1)
builder (3.2.4)
bullet (6.1.4)
bullet (6.1.5)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
bundler-audit
bundler-audit (0.8.0)
bundler-audit (0.9.0.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
climate_control (1.0.1)
coderay (1.1.3)
commonmarker (0.22.0)
commonmarker (0.23.2)
concurrent-ruby (1.1.9)
connection_pool (2.2.5)
crack (0.4.5)
rexml
crass (1.0.6)
cypress-on-rails (1.10.1)
cypress-on-rails (1.11.0)
rack
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
@ -150,7 +151,7 @@ GEM
database_cleaner-core (2.0.1)
datetime_picker_rails (0.0.7)
momentjs-rails (>= 2.8.1)
ddtrace (0.51.1)
ddtrace (0.53.0)
ffi (~> 1.0)
msgpack
declarative (0.0.20)
@ -174,12 +175,14 @@ GEM
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.3)
down (5.2.4)
addressable (~> 2.8)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
email_reply_trimmer (0.1.13)
erubi (1.10.0)
et-orbi (1.2.4)
erubis (2.7.0)
et-orbi (1.2.5)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@ -190,7 +193,7 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (2.18.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@ -198,10 +201,15 @@ GEM
faraday (~> 1.0)
fcm (1.0.3)
faraday (~> 1)
ffi (1.15.3)
ffi (1.15.4)
flag_shih_tzu (0.3.23)
flay (2.12.1)
erubis (~> 2.7.0)
path_expander (~> 1.0)
ruby_parser (~> 3.0)
sexp_processor (~> 4.0)
foreman (0.87.2)
fugit (1.5.0)
fugit (1.5.2)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
gapic-common (0.3.4)
@ -210,9 +218,9 @@ GEM
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.6.7)
geocoder (1.7.0)
gli (2.20.1)
globalid (0.5.2)
globalid (1.0.0)
activesupport (>= 5.0)
google-apis-core (0.4.1)
addressable (~> 2.5, >= 2.5.1)
@ -223,9 +231,9 @@ GEM
retriable (>= 2.0, < 4.a)
rexml
webrick
google-apis-iamcredentials_v1 (0.6.0)
google-apis-iamcredentials_v1 (0.7.0)
google-apis-core (>= 0.4, < 2.a)
google-apis-storage_v1 (0.6.0)
google-apis-storage_v1 (0.8.0)
google-apis-core (>= 0.4, < 2.a)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
@ -238,7 +246,7 @@ GEM
google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.1.0)
google-cloud-errors (1.2.0)
google-cloud-storage (1.34.1)
addressable (~> 2.5)
digest-crc (~> 0.4)
@ -247,32 +255,32 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.17.3)
google-protobuf (3.17.3-universal-darwin)
google-protobuf (3.17.3-x86_64-linux)
googleapis-common-protos (1.3.11)
google-protobuf (3.19.2)
google-protobuf (3.19.2-x86_64-darwin)
google-protobuf (3.19.2-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.1.0)
googleapis-common-protos-types (1.2.0)
google-protobuf (~> 3.14)
googleauth (0.17.0)
googleauth (0.17.1)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (~> 0.14)
signet (~> 0.15)
groupdate (5.2.2)
activesupport (>= 5)
grpc (1.38.0)
google-protobuf (~> 3.15)
grpc (1.41.0)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-universal-darwin)
google-protobuf (~> 3.15)
grpc (1.41.0-universal-darwin)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-x86_64-linux)
google-protobuf (~> 3.15)
grpc (1.41.0-x86_64-linux)
google-protobuf (~> 3.17)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (0.2.24)
@ -283,14 +291,16 @@ GEM
hashdiff (1.0.1)
hashie (4.1.0)
hkdf (0.3.0)
html2text (0.2.1)
nokogiri (~> 1.6)
http-accept (1.7.0)
http-cookie (1.0.4)
domain_name (~> 0.5)
httparty (0.18.1)
httparty (0.20.0)
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.8.10)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
@ -310,7 +320,7 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.2.3)
jwt (2.3.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -331,28 +341,28 @@ GEM
addressable (~> 2.7)
letter_opener (1.7.0)
launchy (~> 2.2)
line-bot-api (1.21.0)
liquid (5.0.1)
listen (3.6.0)
line-bot-api (1.22.0)
liquid (5.1.0)
listen (3.7.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.12.0)
loofah (2.13.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.1)
marcel (1.0.2)
maxminddb (0.1.22)
memoist (0.16.2)
method_source (1.0.0)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2021.0704)
mime-types-data (3.2021.0901)
mini_magick (4.11.0)
mini_mime (1.1.1)
mini_mime (1.1.2)
mini_portile2 (2.5.3)
minitest (5.14.4)
mock_redis (0.28.0)
minitest (5.15.0)
mock_redis (0.29.0)
ruby2_keywords
momentjs-rails (2.20.1)
railties (>= 3.1)
@ -363,7 +373,7 @@ GEM
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
netrc (0.11.0)
newrelic_rpm (7.2.0)
newrelic_rpm (8.0.0)
nio4r (2.5.8)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
@ -377,9 +387,10 @@ GEM
oauth (0.5.6)
orm_adapter (0.5.0)
os (1.1.1)
parallel (1.20.1)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.2.3)
procore-sift (0.16.0)
rails (> 4.2.0)
@ -389,12 +400,12 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.4.0)
puma (5.5.2)
nio4r (~> 2.0)
pundit (2.1.0)
pundit (2.1.1)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.5.2)
racc (1.6.0)
rack (2.2.3)
rack-attack (6.5.0)
rack (>= 1.0, < 3)
@ -405,29 +416,29 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
actionmailer (= 6.1.4.1)
actionpack (= 6.1.4.1)
actiontext (= 6.1.4.1)
actionview (= 6.1.4.1)
activejob (= 6.1.4.1)
activemodel (= 6.1.4.1)
activerecord (= 6.1.4.1)
activestorage (= 6.1.4.1)
activesupport (= 6.1.4.1)
rails (6.1.4.3)
actioncable (= 6.1.4.3)
actionmailbox (= 6.1.4.3)
actionmailer (= 6.1.4.3)
actionpack (= 6.1.4.3)
actiontext (= 6.1.4.3)
actionview (= 6.1.4.3)
activejob (= 6.1.4.3)
activemodel (= 6.1.4.3)
activerecord (= 6.1.4.3)
activestorage (= 6.1.4.3)
activesupport (= 6.1.4.3)
bundler (>= 1.15.0)
railties (= 6.1.4.1)
railties (= 6.1.4.3)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.1)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
railties (6.1.4.1)
actionpack (= 6.1.4.1)
activesupport (= 6.1.4.1)
railties (6.1.4.3)
actionpack (= 6.1.4.3)
activesupport (= 6.1.4.3)
method_source
rake (>= 0.13)
thor (~> 1.0)
@ -454,6 +465,10 @@ GEM
netrc (~> 0.8)
retriable (3.1.2)
rexml (3.2.5)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
rspec-mocks (~> 3.10.0)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
@ -462,7 +477,7 @@ GEM
rspec-mocks (3.10.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-rails (5.0.1)
rspec-rails (5.0.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
@ -471,35 +486,34 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.2)
rubocop (1.18.4)
rubocop (1.22.1)
parallel (~> 1.10)
parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.8.0, < 2.0)
rubocop-ast (>= 1.12.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.8.0)
rubocop-ast (1.12.0)
parser (>= 3.0.1.1)
rubocop-performance (1.11.4)
rubocop-performance (1.11.5)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.11.3)
rubocop-rails (2.12.3)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.4.0)
rubocop (~> 1.0)
rubocop-ast (>= 1.1.0)
rubocop-rspec (2.5.0)
rubocop (~> 1.19)
ruby-progressbar (1.11.0)
ruby-vips (2.1.2)
ruby-vips (2.1.3)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
ruby2ruby (2.4.4)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_parser (3.16.0)
ruby_parser (3.17.0)
sexp_processor (~> 4.15, >= 4.15.1)
sassc (2.4.0)
ffi (~> 1.9)
@ -509,38 +523,38 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (4.1.1)
scout_apm (4.1.2)
parser
seed_dump (3.3.1)
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (4.6.4)
sentry-rails (4.7.3)
railties (>= 5.0)
sentry-ruby-core (~> 4.6.0)
sentry-ruby (4.6.4)
sentry-ruby-core (~> 4.7.0)
sentry-ruby (4.7.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (>= 1.0)
sentry-ruby-core (= 4.6.4)
sentry-ruby-core (4.6.4)
sentry-ruby-core (= 4.7.3)
sentry-ruby-core (4.7.3)
concurrent-ruby
faraday
sentry-sidekiq (4.6.4)
sentry-ruby-core (~> 4.6.0)
sentry-sidekiq (4.7.3)
sentry-ruby-core (~> 4.7.0)
sidekiq (>= 3.0)
sexp_processor (4.15.3)
shoulda-matchers (5.0.0)
activesupport (>= 5.2.0)
sidekiq (6.2.1)
sidekiq (6.2.2)
connection_pool (>= 2.2.2)
rack (~> 2.0)
redis (>= 4.2.0)
sidekiq-cron (1.2.0)
fugit (~> 1.1)
sidekiq (>= 4.2.1)
signet (0.15.0)
addressable (~> 2.3)
signet (0.16.0)
addressable (~> 2.8)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
@ -562,9 +576,9 @@ GEM
sprockets (4.0.2)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.2.2)
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.6.2)
statsd-ruby (1.5.0)
@ -583,15 +597,15 @@ GEM
oauth
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2021.1)
tzinfo-data (1.2021.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (2.0.0)
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
uri_template (0.7.0)
valid_email2 (4.0.0)
@ -604,11 +618,11 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.13.0)
addressable (>= 2.3.6)
webmock (3.14.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.0)
webpacker (5.4.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@ -621,7 +635,7 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
zeitwerk (2.4.2)
zeitwerk (2.5.1)
PLATFORMS
arm64-darwin-20
@ -647,6 +661,7 @@ DEPENDENCIES
bullet
bundle-audit
byebug
climate_control
commonmarker
cypress-on-rails (~> 1.0)
database_cleaner
@ -656,11 +671,13 @@ DEPENDENCIES
devise_token_auth
dotenv-rails
down (~> 5.0)
email_reply_trimmer
facebook-messenger
factory_bot_rails
faker
fcm
flag_shih_tzu
flay
foreman
geocoder
google-cloud-dialogflow
@ -669,6 +686,7 @@ DEPENDENCIES
haikunator
hairtrigger
hashie
html2text
image_processing
jbuilder
json_refs
@ -696,6 +714,7 @@ DEPENDENCIES
redis-namespace
responders
rest-client
rspec
rspec-rails (~> 5.0.0)
rubocop
rubocop-performance

View file

@ -1,7 +1,11 @@
The MIT License (MIT)
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
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

View file

@ -6,7 +6,10 @@
<p align="center">
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
<img alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
</a>
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
</p>
@ -17,7 +20,6 @@ ___
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
<img src="https://img.shields.io/github/license/chatwoot/chatwoot" alt="License">
<img src="https://img.shields.io/github/commit-activity/m/chatwoot/chatwoot" alt="Commits-per-month">
<a title="Crowdin" target="_self" href="https://chatwoot.crowdin.com/chatwoot"><img src="https://badges.crowdin.net/e/37ced7eba411064bd792feb3b7a28b16/localized.svg"></a>
<a href="https://discord.gg/cJXdrwS"><img src="https://img.shields.io/discord/647412545203994635" alt="Discord"></a>
@ -26,34 +28,39 @@ ___
<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>
</p>
<img src="https://s3.us-west-2.amazonaws.com/gh-assets.chatwoot.com/chatwoot-dashboard-assets.png" width="100%" alt="Chat dashboard"/>
<img src="https://chatwoot-public-assets.s3.amazonaws.com/github/screenshot.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
Chatwoot gives an integrated view of conversations happening in different communication channels.
It supports the following conversation channels:
Chatwoot 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.
- **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.
- **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
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
- **API Channel**: Build custom communication channels using our API channel.
- **Email (beta)**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
And more.
Other features include:
- **Multi-brand inboxes**: Manage multiple brands or pages using a single dashboard.
- **Private notes**: Inter team communication is possible using private notes in a conversation.
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
- **Conversation Labels**: Use conversation labelling to create custom workflows.
- **Conversation Labels**: Use conversation labels to create custom workflows.
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot would 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 will send an email to the customer under the agent name so that the user can continue the conversation over the email.
- **Multi-lingual support**: Chatwoot supports 10+ languages.
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
@ -81,9 +88,18 @@ 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.
### DigitalOcean 1-Click Kubernetes deployment
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">
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
</a>
### Other deployment options
Please follow [deployment architecture guide](https://www.chatwoot.com/docs/deployment/architecture) to deploy with Docker or Caprover.
For other supported options, checkout our [deployment page](https://chatwoot.com/deploy).
## Security
@ -102,4 +118,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2021, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2022, Chatwoot Inc - Released under the MIT License.

View file

@ -42,17 +42,16 @@ class ContactMergeAction
end
def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number custom_attributes]
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)
# retaining old pubsub token to notify the contacts that are listening
mergee_pubsub_token = mergee_contact.pubsub_token
@mergee_contact.destroy!
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token])
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

View file

@ -33,7 +33,8 @@ class ContactBuilder
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],
additional_attributes: contact_attributes[:additional_attributes]
additional_attributes: contact_attributes[:additional_attributes],
custom_attributes: contact_attributes[:custom_attributes]
)
end

View file

@ -4,7 +4,7 @@ class ContactInboxBuilder
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api'].include? @inbox.channel_type
return unless ['Channel::TwilioSms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
@ -14,12 +14,20 @@ class ContactInboxBuilder
def generate_source_id
return twilio_source_id if @inbox.channel_type == 'Channel::TwilioSms'
return wa_source_id if @inbox.channel_type == 'Channel::Whatsapp'
return @contact.email if @inbox.channel_type == 'Channel::Email'
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Api'
nil
end
def wa_source_id
return unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
"#{@contact.phone_number}.delete('+')"
end
def twilio_source_id
return unless @contact.phone_number

View file

@ -4,10 +4,11 @@
# 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::Facebook::MessageBuilder
class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo: false)
super()
@response = response
@inbox = inbox
@outgoing_echo = outgoing_echo
@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder
def build_message
@message = conversation.messages.create!(message_params)
@attachments.each do |attachment|
process_attachment(attachment)
end
end
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
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]
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 ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder
))
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)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']

View file

@ -0,0 +1,145 @@
# 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
Sentry.capture_exception(e)
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?
@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
### 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,14 +8,17 @@ class Messages::MessageBuilder
@conversation = conversation
@user = user
@message_type = params[:message_type] || 'outgoing'
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
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)
end
def perform
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
@message.save!
@message
end
@ -34,6 +37,16 @@ class Messages::MessageBuilder
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
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'

View file

@ -0,0 +1,42 @@
class Messages::Messenger::MessageBuilder
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
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]
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].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
end

View file

@ -68,15 +68,14 @@ class V2::ReportBuilder
.count
end
# unscoped removes all scopes added to a model previously
def incoming_messages_count
scope.messages.unscoped.where(account_id: account.id).incoming
scope.messages.incoming.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.count
end
def outgoing_messages_count
scope.messages.unscoped.where(account_id: account.id).outgoing
scope.messages.outgoing.unscope(:order)
.group_by_day(:created_at, range: range, default_value: 0)
.count
end

View file

@ -31,7 +31,7 @@ class RoomChannel < ApplicationCable::Channel
def current_user
@current_user ||= if params[:user_id].blank?
Contact.find_by!(pubsub_token: @pubsub_token)
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
else
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
end

View file

@ -2,6 +2,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
before_action :fetch_agent, except: [:create, :index]
before_action :check_authorization
before_action :find_user, only: [:create]
before_action :validate_limit, only: [:create]
before_action :create_user, only: [:create]
before_action :save_account_user, only: [:create]
@ -9,21 +10,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agents = agents
end
def create; end
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy
@agent.current_account_user.destroy
head :ok
end
def update
@agent.update!(agent_params.except(:role))
@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
private
def check_authorization
@ -47,26 +45,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def save_account_user
AccountUser.create!(
AccountUser.create!({
account_id: Current.account.id,
user_id: @user.id,
role: new_agent_params[:role],
inviter_id: current_user.id
)
}.merge({
role: new_agent_params[:role],
availability: new_agent_params[:availability],
auto_offline: new_agent_params[:auto_offline]
}.compact))
end
def agent_params
params.require(:agent).permit(:email, :name, :role)
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
# intial string ensures the password requirements are met
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: temp_password, password_confirmation: temp_password, inviter: current_user)
end
def agents
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
end
def validate_limit
render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents]
end
end

View file

@ -0,0 +1,21 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
@automation_rules = Current.account.automation_rules
end
def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
end
private
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [:intiated_at] }]
)
end
end

View file

@ -12,9 +12,10 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
page_access_token: page_access_token
)
@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)
rescue StandardError => e
Rails.logger.info e
Sentry.capture_exception(e)
end
end
@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
@page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts'))
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]
def reauthorize_page
if @inbox&.facebook?
@ -45,8 +55,13 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
def update_fb_page(fb_page_id, access_token)
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)
set_instagram_id(access_token, fb_page)
fb_page&.reauthorized!
rescue StandardError => e
Sentry.capture_exception(e)
end
end
def get_fb_page(fb_page_id)
@ -59,7 +74,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
end
def long_lived_token(omniauth_token)
koala = Koala::Facebook::OAuth.new(ENV['FB_APP_ID'], ENV['FB_APP_SECRET'])
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e
Rails.logger.info e

View file

@ -28,7 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id,
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
end
end

View file

@ -33,7 +33,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
def canned_responses
if params[:search]
Current.account.canned_responses.where('short_code ILIKE ?', "#{params[:search]}%")
Current.account.canned_responses.where('short_code ILIKE :search OR content ILIKE :search', search: "%#{params[:search]}%")
else
Current.account.canned_responses
end

View file

@ -0,0 +1,9 @@
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,5 +1,4 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::BaseController
before_action :ensure_contact
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
before_action :ensure_inbox, only: [:create]
def create
@ -13,8 +12,4 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
@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

View file

@ -1,8 +1,8 @@
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
def index
@conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id])
).where(inbox_id: inbox_ids, contact_id: @contact.id)
end
private
@ -14,8 +14,4 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
[]
end
end
def permitted_params
params.permit(:contact_id)
end
end

View file

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

View file

@ -0,0 +1,32 @@
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,17 +1,19 @@
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
include Sift
sort_on :email, type: :string
sort_on :name, 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, type: :datetime
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
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes]
before_action :set_include_contact_inboxes, only: [:index, :search]
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter]
def index
@contacts_count = resolved_contacts.count
@ -50,11 +52,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
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
@all_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
def create
ActiveRecord::Base.transaction do
@contact = Current.account.contacts.new(contact_params)
@ -66,11 +81,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def update
@contact.assign_attributes(contact_update_params)
@contact.save!
rescue ActiveRecord::RecordInvalid => e
render json: {
message: e.record.errors.full_messages.join(', '),
contact: Current.account.contacts.find_by(email: contact_params[:email])
}, status: :unprocessable_entity
end
def destroy
@ -91,10 +101,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def resolved_contacts
return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts
.where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
.or(Current.account.contacts.where.not(identifier: [nil, '']))
@resolved_contacts = Current.account.contacts.resolved_contacts
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts
end

View file

@ -2,7 +2,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create]
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :contact_inbox, only: [:create]
def index
@ -31,6 +31,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@conversations_count = result[:count]
end
def mute
@conversation.mute!
head :ok
@ -60,17 +66,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_typing_status
case params[:typing_status]
when 'on'
trigger_typing_event(CONVERSATION_TYPING_ON)
trigger_typing_event(CONVERSATION_TYPING_ON, params[:is_private])
when 'off'
trigger_typing_event(CONVERSATION_TYPING_OFF)
trigger_typing_event(CONVERSATION_TYPING_OFF, params[:is_private])
end
head :ok
end
def update_last_seen
@conversation.agent_last_seen_at = DateTime.now.utc
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee?
@conversation.save!
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, DateTime.now.utc)
@conversation.update_column(:assignee_last_seen_at, DateTime.now.utc) if assignee?
# rubocop:enable Rails/SkipsModelValidations
end
def custom_attributes
@ -86,9 +93,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def trigger_typing_event(event)
def trigger_typing_event(event, is_private)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user, is_private: is_private)
end
def conversation
@ -130,7 +137,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until]
snoozed_until: params[:snoozed_until],
assignee_id: params[:assignee_id],
team_id: params[:team_id]
}.merge(status)
end

View file

@ -25,9 +25,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
private
def fetch_custom_attributes_definitions
@custom_attribute_definitions = Current.account.custom_attribute_definitions.where(
attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL
)
@custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
end
def fetch_custom_attribute_definition
@ -41,7 +39,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
:attribute_display_type,
:attribute_key,
:attribute_model,
:default_value
attribute_values: []
)
end

View file

@ -1,11 +1,11 @@
class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :current_agents_ids, only: [:update]
before_action :current_agents_ids, only: [:create, :update]
def create
authorize @inbox, :create?
ActiveRecord::Base.transaction do
params[:user_ids].map { |user_id| @inbox.add_member(user_id) }
agents_to_be_added_ids.map { |user_id| @inbox.add_member(user_id) }
end
fetch_updated_agents
end

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::InboxesHelper
before_action :fetch_inbox, except: [:index, :create]
before_action :fetch_agent_bot, only: [:set_agent_bot]
# we are already handling the authorization in fetch inbox
@ -41,9 +42,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update(permitted_params.except(:channel))
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
channel_attributes = get_channel_attributes(@inbox.channel_type)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel]) if permitted_params(channel_attributes)[:channel].present?
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
@ -96,6 +102,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type))
when 'telegram'
Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type))
when 'whatsapp'
Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type))
end
end
@ -110,23 +118,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def permitted_params(channel_attributes = [])
params.permit(
:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
channel: [:type, *channel_attributes]
)
end
def get_channel_attributes(channel_type)
case channel_type
when 'Channel::WebWidget'
Channel::WebWidget::EDITABLE_ATTRS
when 'Channel::Api'
Channel::Api::EDITABLE_ATTRS
when 'Channel::Email'
Channel::Email::EDITABLE_ATTRS
when 'Channel::Telegram'
Channel::Telegram::EDITABLE_ATTRS
when 'Channel::Line'
Channel::Line::EDITABLE_ATTRS
if channel_type.constantize.const_defined?('EDITABLE_ATTRS')
channel_type.constantize::EDITABLE_ATTRS.presence
else
[]
end

View file

@ -8,7 +8,7 @@ class Api::V1::Accounts::TeamMembersController < Api::V1::Accounts::BaseControll
def create
ActiveRecord::Base.transaction do
@team_members = params[:user_ids].map { |user_id| @team.add_member(user_id) }
@team_members = members_to_be_added_ids.map { |user_id| @team.add_member(user_id) }
end
end

View file

@ -55,7 +55,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if ENV.fetch('ENABLE_ACCOUNT_SIGNUP', true) == 'false'
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end
def pundit_user

View file

@ -7,6 +7,12 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
render json: notification_subscription
end
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy
head :ok
end
private
def set_user

View file

@ -1,9 +1,7 @@
class Api::V1::ProfilesController < Api::BaseController
before_action :set_user
def show
render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user }
end
def show; end
def update
if password_params[:password].present?
@ -15,19 +13,31 @@ class Api::V1::ProfilesController < Api::BaseController
@user.update!(profile_params)
end
def avatar
@user.avatar.attachment.destroy! if @user.avatar.attached?
head :ok
end
def availability
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
private
def set_user
@user = current_user
end
def availability_params
params.require(:profile).permit(:account_id, :availability)
end
def profile_params
params.require(:profile).permit(
:email,
:name,
:display_name,
:avatar,
:availability,
ui_settings: {}
)
end

View file

@ -1,5 +1,5 @@
class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
before_action :process_hmac
before_action :process_hmac, only: [:update]
def show; end
@ -8,18 +8,35 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact: @contact,
params: permitted_params.to_h.deep_symbolize_keys
)
render json: contact_identify_action.perform
@contact = contact_identify_action.perform
end
# TODO : clean up this with proper routes delete contacts/custom_attributes
def destroy_custom_attributes
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
@contact.save!
render json: @contact
end
private
def process_hmac
return if params[:identifier_hash].blank?
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
return unless should_verify_hmac?
render json: { error: 'HMAC failed: Invalid Identifier Hash Provided' }, status: :unauthorized unless valid_hmac?
@contact_inbox.update(hmac_verified: true)
end
def should_verify_hmac?
return false if params[:identifier_hash].blank? && !@web_widget.hmac_mandatory
# Taking an extra caution that the hmac is triggered whenever identifier is present
return false if params[:custom_attributes].present? && params[:identifier].blank?
true
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',

View file

@ -31,13 +31,18 @@ module RequestExceptionHandler
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message)
render json: { error: message }, status: :internal_server_error
end
def render_record_invalid(exception)
render json: {
message: exception.record.errors.full_messages.join(', ')
message: exception.record.errors.full_messages.join(', '),
attributes: exception.record.errors.attribute_names
}, status: :unprocessable_entity
end

View file

@ -27,9 +27,7 @@ class DashboardController < ActionController::Base
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST'
).merge(
APP_VERSION: Chatwoot.config[:version]
)
).merge(app_config)
end
def ensure_installation_onboarding
@ -39,4 +37,11 @@ class DashboardController < ActionController::Base
def allow_iframe_requests
response.headers.delete('X-Frame-Options')
end
def app_config
{ APP_VERSION: Chatwoot.config[:version],
VAPID_PUBLIC_KEY: VapidService.public_key,
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') }
end
end

View file

@ -28,10 +28,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
end
def create_reset_token_link(user)
raw, enc = Devise.token_generator.generate(user.class, :reset_password_token)
user.reset_password_token = enc
user.reset_password_sent_at = Time.now.utc
user.save(validate: false)
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{raw}"
token = user.send(:set_reset_password_token)
"/app/auth/password/edit?config=default&redirect_url=&reset_password_token=#{token}"
end
end

View file

@ -0,0 +1,21 @@
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def show
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
# ref: https://github.com/rubocop/rubocop/issues/7767
# rubocop:disable Style/HashTransformValues
@fb_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
# rubocop:enable Style/HashTransformValues
end
def create
params['app_config'].each do |key, value|
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
i.save!
end
redirect_to super_admin_app_config_url
end
end

View file

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

View file

@ -0,0 +1,6 @@
class Webhooks::WhatsappController < ActionController::API
def process_payload
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
head :ok
end
end

View file

@ -29,21 +29,21 @@ class WidgetsController < ActionController::Base
def set_contact
return if @auth_token_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
@contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id]
)
@contact = contact_inbox ? contact_inbox.contact : nil
@contact = @contact_inbox ? @contact_inbox.contact : nil
end
def build_contact
return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = contact_inbox.contact
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
@contact = @contact_inbox.contact
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end

View file

@ -12,6 +12,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
avatar_url: AvatarField,
id: Field::Number,
name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
description: Field::String,
outgoing_url: Field::String,
created_at: Field::DateTime,
@ -26,6 +27,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
COLLECTION_ATTRIBUTES = %i[
id
avatar_url
account
name
outgoing_url
].freeze
@ -34,7 +36,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
account
name
description
outgoing_url
@ -45,6 +47,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
account
description
outgoing_url
].freeze

View file

@ -15,7 +15,9 @@ class AsyncDispatcher < BaseDispatcher
EventListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
WebhookListener.instance
NotificationListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
]
end
end

View file

@ -5,6 +5,6 @@ class SyncDispatcher < BaseDispatcher
end
def listeners
[ActionCableListener.instance, AgentBotListener.instance, NotificationListener.instance]
[ActionCableListener.instance, AgentBotListener.instance]
end
end

View file

@ -1,5 +1,30 @@
class ConversationDrop < BaseDrop
include MessageFormatHelper
def display_id
@obj.try(:display_id)
end
def contact_name
@obj.try(:contact).name.capitalize || 'Customer'
end
def recent_messages
@obj.try(:recent_messages).map do |message|
{
'sender' => message_sender_name(message.sender),
'content' => render_message_content(transform_user_mention_content(message.content)),
'attachments' => message.attachments.map(&:file_url)
}
end
end
private
def message_sender_name(sender)
return 'Bot' if sender.blank?
return contact_name if sender.instance_of?(Contact)
sender&.available_name || sender&.name
end
end

View file

@ -1,2 +1,5 @@
class InboxDrop < BaseDrop
def name
@obj.try(:name)
end
end

View file

@ -6,7 +6,7 @@ class MessageDrop < BaseDrop
end
def text_content
content = @obj.try(:content)
transform_user_mention_content content
content = @obj.try(:content) || ''
render_message_content(transform_user_mention_content(content))
end
end

View file

@ -2,6 +2,6 @@ require 'administrate/field/base'
class AvatarField < Administrate::Field::Base
def avatar_url
data.presence || '/admin/avatar.png'
data.presence&.gsub('?d=404', '?d=mp')
end
end

View file

@ -70,8 +70,13 @@ class ConversationFinder
end
def find_all_conversations
if params[:conversation_type] == 'mention'
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = current_account.conversations.where(id: conversation_ids)
else
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
end
end
def filter_by_assignee_type
case @assignee_type
@ -121,8 +126,12 @@ class ConversationFinder
def conversations
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
if params[:conversation_type] == 'mention'
@conversations.page(current_page)
else
@conversations.latest.page(current_page)
end
end
end

View file

@ -0,0 +1,33 @@
module Api::V1::InboxesHelper
def validate_email_channel(attributes)
channel_data = permitted_params(attributes)[:channel]
validate_imap(channel_data)
validate_smtp(channel_data)
end
private
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_email],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
Mail.connection do # rubocop:disable:block
end
end
def validate_smtp(channel_data)
return unless channel_data.key?('smtp_enabled') && channel_data[:smtp_enabled]
smtp = Net::SMTP.start(channel_data[:smtp_address], channel_data[:smtp_port], channel_data[:smtp_domain], channel_data[:smtp_email],
channel_data[:smtp_password], :login)
smtp.finish unless smtp&.nil?
end
end

View file

@ -1,16 +1,29 @@
module FileTypeHelper
# NOTE: video, audio, image, etc are filetypes previewable in frontend
def file_type(content_type)
return :image if [
'image/jpeg',
'image/png',
'image/gif',
'image/tiff',
'image/bmp'
].include?(content_type)
return :video if content_type.include?('video/')
return :image if image_file?(content_type)
return :video if video_file?(content_type)
return :audio if content_type.include?('audio/')
:file
end
def image_file?(content_type)
[
'image/jpeg',
'image/png',
'image/gif',
'image/bmp',
'image/webp'
].include?(content_type)
end
def video_file?(content_type)
[
'video/ogg',
'video/mp4',
'video/webm',
'video/quicktime'
].include?(content_type)
end
end

View file

@ -1,6 +1,13 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
message_content.gsub(MENTION_REGEX, '\1')
end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
end
end

View file

@ -8,6 +8,7 @@
:has-accounts="hasAccounts"
/>
<woot-snackbar-box />
<network-notification />
</div>
</template>
@ -15,6 +16,7 @@
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import WootSnackbarBox from './components/SnackbarContainer';
import NetworkNotification from './components/NetworkNotification';
import { accountIdFromPathname } from './helper/URLHelper';
export default {
@ -23,6 +25,7 @@ export default {
components: {
WootSnackbarBox,
AddAccountModal,
NetworkNotification,
},
data() {

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class AccountActions extends ApiClient {
constructor() {
super('actions', { accountScoped: true });
}
merge(parentId, childId) {
return axios.post(`${this.url}/contact_merge`, {
base_contact_id: parentId,
mergee_contact_id: childId,
});
}
}
export default new AccountActions();

View file

@ -6,8 +6,8 @@ class AttributeAPI extends ApiClient {
super('custom_attribute_definitions', { accountScoped: true });
}
getAttributesByModel(modelId) {
return axios.get(`${this.url}?attribute_model=${modelId}`);
getAttributesByModel() {
return axios.get(this.url);
}
}

View file

@ -161,9 +161,13 @@ export default {
});
},
updateAvailability({ availability }) {
return axios.put(endPoints('profileUpdate').url, {
profile: { availability },
updateAvailability(availabilityData) {
return axios.post(endPoints('availabilityUpdate').url, {
profile: { ...availabilityData },
});
},
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},
};

View file

@ -2,7 +2,27 @@ import ApiClient from './ApiClient';
class ContactNotes extends ApiClient {
constructor() {
super('contact_notes', { accountScoped: true });
super('notes', { accountScoped: true });
this.contactId = null;
}
get url() {
return `${this.baseUrl()}/contacts/${this.contactId}/notes`;
}
get(contactId) {
this.contactId = contactId;
return super.get();
}
create(contactId, content) {
this.contactId = contactId;
return super.create({ content });
}
delete(contactId, id) {
this.contactId = contactId;
return super.delete(id);
}
}

View file

@ -53,6 +53,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
return axios.post(requestURL, queryPayload);
}
importContacts(file) {
const formData = new FormData();
formData.append('import_file', file);
@ -60,6 +65,12 @@ class ContactAPI extends ApiClient {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
destroyCustomAttributes(contactId, customAttributes) {
return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
}
export default new ContactAPI();

View file

@ -13,6 +13,9 @@ const endPoints = {
profileUpdate: {
url: '/api/v1/profile',
},
availabilityUpdate: {
url: '/api/v1/profile/availability',
},
logout: {
url: 'auth/sign_out',
},
@ -33,6 +36,10 @@ const endPoints = {
},
params: { omniauth_token: '' },
},
deleteAvatar: {
url: '/api/v1/profile/avatar',
},
};
export default page => {

View file

@ -6,7 +6,15 @@ class ConversationApi extends ApiClient {
super('conversations', { accountScoped: true });
}
get({ inboxId, status, assigneeType, page, labels, teamId }) {
get({
inboxId,
status,
assigneeType,
page,
labels,
teamId,
conversationType,
}) {
return axios.get(this.url, {
params: {
inbox_id: inboxId,
@ -15,6 +23,15 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType,
page,
labels,
conversation_type: conversationType,
},
});
}
filter(payload) {
return axios.post(`${this.url}/filter`, payload.queryData, {
params: {
page: payload.page,
},
});
}
@ -51,9 +68,10 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${id}/update_last_seen`);
}
toggleTyping({ conversationId, status }) {
toggleTyping({ conversationId, status, isPrivate }) {
return axios.post(`${this.url}/${conversationId}/toggle_typing_status`, {
typing_status: status,
is_private: isPrivate,
});
}
@ -65,7 +83,7 @@ class ConversationApi extends ApiClient {
return axios.post(`${this.url}/${conversationId}/unmute`);
}
meta({ inboxId, status, assigneeType, labels, teamId }) {
meta({ inboxId, status, assigneeType, labels, teamId, conversationType }) {
return axios.get(`${this.url}/meta`, {
params: {
inbox_id: inboxId,
@ -73,6 +91,7 @@ class ConversationApi extends ApiClient {
assignee_type: assigneeType,
labels,
team_id: teamId,
conversation_type: conversationType,
},
});
}
@ -80,6 +99,12 @@ class ConversationApi extends ApiClient {
sendEmailTranscript({ conversationId, email }) {
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
}
updateCustomAttributes({ conversationId, customAttributes }) {
return axios.post(`${this.url}/${conversationId}/custom_attributes`, {
custom_attributes: customAttributes,
});
}
}
export default new ConversationApi();

View file

@ -8,6 +8,8 @@ export const buildCreatePayload = ({
contentAttributes,
echoId,
file,
ccEmails = '',
bccEmails = '',
}) => {
let payload;
if (file) {
@ -18,12 +20,16 @@ export const buildCreatePayload = ({
}
payload.append('private', isPrivate);
payload.append('echo_id', echoId);
payload.append('cc_emails', ccEmails);
payload.append('bcc_emails', bccEmails);
} else {
payload = {
content: message,
private: isPrivate,
echo_id: echoId,
content_attributes: contentAttributes,
cc_emails: ccEmails,
bcc_emails: bccEmails,
};
}
return payload;
@ -41,6 +47,8 @@ class MessageApi extends ApiClient {
contentAttributes,
echo_id: echoId,
file,
ccEmails = '',
bccEmails = '',
}) {
return axios({
method: 'post',
@ -51,6 +59,8 @@ class MessageApi extends ApiClient {
contentAttributes,
echoId,
file,
ccEmails,
bccEmails,
}),
});
}

View file

@ -35,6 +35,12 @@ class ReportsAPI extends ApiClient {
params: { since, until },
});
}
getTeamReports(since, until) {
return axios.get(`${this.url}/teams`, {
params: { since, until },
});
}
}
export default new ReportsAPI();

View file

@ -0,0 +1,23 @@
import accountActionsAPI from '../accountActions';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => {
it('creates correct instance', () => {
expect(accountActionsAPI).toBeInstanceOf(ApiClient);
expect(accountActionsAPI).toHaveProperty('merge');
});
describeWithAPIMock('API calls', context => {
it('#merge', () => {
accountActionsAPI.merge(1, 2);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/actions/contact_merge',
{
base_contact_id: 1,
mergee_contact_id: 2,
}
);
});
});
});

View file

@ -11,6 +11,7 @@ describe('#ContactsAPI', () => {
expect(contactAPI).toHaveProperty('update');
expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations');
expect(contactAPI).toHaveProperty('filter');
});
describeWithAPIMock('API calls', context => {
@ -60,6 +61,16 @@ describe('#ContactsAPI', () => {
);
});
it('#destroyCustomAttributes', () => {
contactAPI.destroyCustomAttributes(1, ['cloudCustomer']);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/1/destroy_custom_attributes',
{
custom_attributes: ['cloudCustomer'],
}
);
});
it('#importContacts', () => {
const file = 'file';
contactAPI.importContacts(file);
@ -71,6 +82,24 @@ describe('#ContactsAPI', () => {
}
);
});
it('#filter', () => {
const queryPayload = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['fayaz'],
query_operator: null,
},
],
};
contactAPI.filter(1, 'name', queryPayload);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/filter?include_contact_inboxes=false&page=1&sort=name',
queryPayload
);
});
});
});

View file

@ -19,6 +19,7 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('unmute');
expect(conversationAPI).toHaveProperty('meta');
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
expect(conversationAPI).toHaveProperty('filter');
});
describeWithAPIMock('API calls', context => {
@ -160,5 +161,54 @@ describe('#ConversationAPI', () => {
}
);
});
it('#updateCustomAttributes', () => {
conversationAPI.updateCustomAttributes({
conversationId: 45,
customAttributes: { order_d: '1001' },
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/custom_attributes',
{
custom_attributes: { order_d: '1001' },
}
);
});
it('#filter', () => {
const payload = {
page: 1,
queryData: {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending', 'resolved'],
query_operator: 'and',
},
{
attribute_key: 'assignee',
filter_operator: 'equal_to',
values: [3],
query_operator: 'and',
},
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: ['This is a test'],
query_operator: null,
},
],
},
};
conversationAPI.filter(payload);
expect(
context.axiosMock.post
).toHaveBeenCalledWith(
'/api/v1/conversations/filter',
payload.queryData,
{ params: { page: payload.page } }
);
});
});
});

View file

@ -35,12 +35,14 @@ describe('#ConversationAPI', () => {
message: 'test content',
echoId: 12,
isPrivate: true,
file: new Blob(['test-content'], { type: 'application/pdf' }),
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('content')).toEqual('test content');
expect(formPayload.get('echo_id')).toEqual('12');
expect(formPayload.get('private')).toEqual('true');
expect(formPayload.get('cc_emails')).toEqual('');
});
it('builds object payload if file is not available', () => {
@ -56,6 +58,8 @@ describe('#ConversationAPI', () => {
private: false,
echo_id: 12,
content_attributes: { in_reply_to: 12 },
bcc_emails: '',
cc_emails: '',
});
});
});

View file

@ -16,6 +16,7 @@ describe('#Reports API', () => {
expect(reportsAPI).toHaveProperty('getAgentReports');
expect(reportsAPI).toHaveProperty('getLabelReports');
expect(reportsAPI).toHaveProperty('getInboxReports');
expect(reportsAPI).toHaveProperty('getTeamReports');
});
describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => {
@ -82,5 +83,18 @@ describe('#Reports API', () => {
}
);
});
it('#getTeamReports', () => {
reportsAPI.getTeamReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/teams',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,4 +1,3 @@
/* Enter and leave animations can use different */
/* durations and timing functions. */
.slide-fade-enter-active {
@ -9,7 +8,8 @@
transition: all .3s $ease-out-cubic;
}
.slide-fade-enter, .slide-fade-leave-to {
.slide-fade-enter,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
@ -22,22 +22,33 @@
transform: translateX($space-medium);
}
.conversations-list-enter-active, .conversations-list-leave-active {
.conversations-list-enter-active,
.conversations-list-leave-active {
transition: all .25s $ease-out-cubic;
}
.conversations-list-enter, .conversations-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
.conversations-list-enter,
.conversations-list-leave-to {
opacity: 0;
transform: translateX($space-medium);
}
.menu-list-enter-active, .menu-list-leave-active {
transition: all .2s $ease-out-cubic;
.menu-list-enter-active,
.menu-list-leave-active {
transition: opacity .3s $ease-out-cubic,
transform .2s $ease-out-cubic;
}
.menu-list-enter, .menu-list-leave-to /* .conversations-list-leave-active for <2.1.8 */ {
.menu-list-leave-to {
opacity: 0;
transform: translateX($space-medium);
position: absolute;
transform: translateX($space-small);
}
.menu-list-enter {
opacity: 0;
transform: translateX(-$space-small);
}
.slide-up-enter-active {
@ -48,8 +59,8 @@
transition: all .3s $ease-out-cubic;
}
.slide-up-enter, .slide-up-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
.slide-up-enter,
.slide-up-leave-to {
transform: translateY(-$space-medium);
opacity: 0;
}
@ -60,10 +71,10 @@
transition: transform 0.25s $ease-in-cubic, opacity 0.15s $ease-in-cubic;
}
.menu-slide-enter, .menu-slide-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
transform: translateY($space-small);
.menu-slide-enter,
.menu-slide-leave-to {
opacity: 0;
transform: translateY($space-small);
}
@ -75,10 +86,10 @@
transition: all .1s $ease-out-sine;
}
.toast-fade-enter, .toast-fade-leave-to
/* .toast-fade-leave-active for <2.1.8 */ {
transform: translateY(-$space-small);
.toast-fade-enter,
.toast-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
.modal-fade-enter-active {
@ -89,7 +100,21 @@
transition: all .1s $ease-out-sine;
}
.modal-fade-enter, .modal-fade-leave-to
/* .slide-fade-leave-active for <2.1.8 */ {
.modal-fade-enter,
.modal-fade-leave-to {
opacity: 0;
}
.network-notification-fade-enter-active {
transition: all .1s $ease-in-sine;
}
.network-notification-fade-leave-active {
transition: all .1s $ease-out-sine;
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
transform: translateY(-$space-small);
opacity: 0;
}

View file

@ -9,10 +9,10 @@
.card {
margin-bottom: var(--space-small);
padding: var(--space-small);
padding: var(--space-normal);
}
.button-wrapper .button.link.grey-btn {
.button-wrapper .button.grey-btn {
margin-left: var(--space-normal);
}
@ -21,7 +21,7 @@
font-size: $font-size-mini;
max-width: 15rem;
padding: $space-smaller $space-small;
z-index: 9999;
z-index: 999;
}
code {
@ -49,7 +49,21 @@ code {
.cursor-pointer {
cursor: pointer;
}
// remove when grid gutters are fixed
.columns.with-right-space {
padding-right: var(--space-normal);
}
.badge {
border-radius: var(--border-radius-normal);
}
.padding-right-small {
padding-right: var(--space-one);
}
.margin-right-small {
margin-right: var(--space-small);
}

View file

@ -45,6 +45,9 @@
// 1. Global
// ---------
// Disable contrast warnings in Foundation.
$contrast-warnings: false;
$global-font-size: 10px;
$global-width: 100%;
$global-lineheight: 1.5;
@ -219,9 +222,9 @@ $badge-background: $primary-color;
$badge-color: $white;
$badge-color-alt: $black;
$badge-palette: $foundation-palette;
$badge-padding: 0.3em;
$badge-padding: var(--space-smaller);
$badge-minwidth: 2.1em;
$badge-font-size: 0.6rem;
$badge-font-size: var(--font-size-nano);
// 10. Breadcrumbs
// ---------------
@ -400,7 +403,7 @@ $mediaobject-image-width-stacked: 100%;
$menu-margin: 0;
$menu-margin-nested: $space-medium;
$menu-item-padding: $space-one;
$menu-item-padding: $space-slab;
$menu-item-color-active: $white;
$menu-item-background-active: $color-background;
$menu-icon-spacing: 0.25rem;

View file

@ -55,6 +55,10 @@
justify-content: space-between;
}
.w-100 {
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}

View file

@ -1,3 +1,56 @@
.margin-right-small {
margin-right: var(--space-small);
}
.margin-right-smaller {
margin-right: var(--space-smaller);
}
.margin-left-minus-slab {
margin-left: var(--space-minus-slab);
}
.fs-small {
font-size: var(--font-size-small);
}
.fs-default {
font-size: var(--font-size-default);
}
.fw-medium {
font-weight: var(--font-weight-medium);
}
.p-normal {
padding: var(--space-normal);
}
.overflow-scroll {
overflow: scroll;
}
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.border-right {
border-right: 1px solid var(--color-border);
}
.border-left {
border-left: 1px solid var(--color-border);
}
.bg-white {
background-color: var(--white);
}
.text-y-800 {
color: var(--y-800);
}

View file

@ -44,11 +44,14 @@ $woot-logo-padding: $space-large $space-two;
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-border-dark: #cad0d4;
$color-background: #f4f6fb;
$color-background-light: #f9fafc;
$color-border: var(--s-75);
$color-border-light: var(--s-50);
$color-border-dark: var(--s-100);
$color-background: var(--s-50);
$color-background-light: var(--s-25);
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;

View file

@ -14,7 +14,6 @@
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';
@ -50,3 +49,4 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '~shared/assets/stylesheets/ionicons';
@import 'utility-helpers';

View file

@ -2,8 +2,9 @@
@include elegant-card;
@include border-light;
box-sizing: content-box;
padding: var(--space-small);
width: fit-content;
z-index: 999;
z-index: var(--z-index-very-high);
&.dropdown-pane--open {
display: block;

View file

@ -15,6 +15,10 @@
.multiselect {
margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: .8;
}
.multiselect--active {
>.multiselect__tags {
border-color: $color-woot;
@ -47,6 +51,10 @@
width: 100%;
}
p {
margin-bottom: 0;
}
&.multiselect__option--highlight {
background: var(--white);
color: var(--color-body);
@ -209,3 +217,53 @@
flex-shrink: 0;
}
}
.multiselect-wrap--medium {
$multiselect-height: 4.8rem;
.multiselect__tags,
.multiselect__input {
align-items: center;
display: flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
background: var(--white);
font-size: var(--font-size-small);
height: $multiselect-height;
min-height: $multiselect-height;
}
.multiselect__input {
height: $multiselect-height - $space-micro;
min-height: $multiselect-height - $space-micro;
}
.multiselect__single {
align-items: center;
display: flex;
font-size: var(--font-size-small);
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__placeholder {
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__select {
min-height: $multiselect-height;
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
background: transparent;
}
.multiselect__tags-wrap {
flex-shrink: 0;
}
}

View file

@ -8,7 +8,7 @@
@include background-white;
@include flex;
@include flex-align($x: justify, $y: middle);
@include border-normal-bottom;
border-bottom: 1px solid var(--s-50);
height: $header-height;
min-height: $header-height;

View file

@ -25,6 +25,7 @@ $default-button-height: 4.0rem;
// @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content {
padding-left: var(--space-small);
width: auto;
}
&.expanded {
@ -65,6 +66,32 @@ $default-button-height: 4.0rem;
}
}
&.clear {
&.warning {
color: var(--y-800);
}
&.button--only-icon:hover {
background: var(--w-50);
&.secondary {
background: var(--s-50);
}
&.success {
background: var(--g-50);
}
&.alert {
background: var(--r-50);
}
&.warning {
background: var(--y-100);
}
}
}
// Sizes
&.tiny {
height: var(--space-medium);
@ -103,7 +130,6 @@ $default-button-height: 4.0rem;
padding: 0;
}
}

View file

@ -85,11 +85,6 @@
text-overflow: ellipsis;
white-space: nowrap;
width: 27rem;
.small-icon {
font-size: $font-size-mini;
vertical-align: top;
}
}
.conversation--meta {

View file

@ -17,7 +17,8 @@
}
}
.image {
.image,
.video {
cursor: pointer;
position: relative;
@ -26,7 +27,13 @@
}
.modal-image {
max-width: 85%;
max-height: 90%;
max-width: 90%;
}
.modal-video {
max-height: 75vh;
max-width: 100%;
}
&::before {
@ -35,11 +42,21 @@
content: '';
height: 20%;
left: 0;
opacity: .8;
opacity: 0.8;
position: absolute;
width: 100%;
}
}
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
}
.conversations-list-wrap {
@ -76,7 +93,7 @@
.status--filter {
@include padding($zero null $zero $space-normal);
@include margin($space-smaller $space-slab $zero $zero);
@include margin($zero);
background-color: $color-background-light;
border: 1px solid $color-border;
float: right;
@ -93,7 +110,7 @@
.conversation-panel {
@include flex;
@include flex-weight(1);
@include flex-weight(1 1 1px);
@include margin($zero);
flex-direction: column;
height: 100%;
@ -118,7 +135,7 @@
&.unread--toast {
+.right {
margin-bottom: 0;
margin-bottom: var(--space-micro);
}
+.left {

View file

@ -1,5 +1,4 @@
.error {
#{$all-text-inputs},
select,
.multiselect > .multiselect__tags {
@ -40,4 +39,8 @@ input {
font-size: var(--font-size-small);
height: var(--space-large);
}
.error {
border-color: var(--r-400);
}
}

View file

@ -19,7 +19,7 @@
cursor: pointer;
font-size: $font-size-big;
line-height: $space-normal;
padding: $space-normal $space-two;
padding: $space-normal;
position: absolute;
right: $space-micro;
top: $space-micro;
@ -29,7 +29,6 @@
}
}
.page-top-bar {
@include padding($space-large $space-large $zero);
@ -48,13 +47,16 @@
position: relative;
width: 60rem;
&.medium {
max-width: 80%;
width: 90rem;
}
.content-box {
@include padding($zero);
height: auto;
}
h2 {
color: $color-heading;
font-size: $font-size-medium;
@ -89,15 +91,19 @@
button {
font-size: $font-size-small;
}
&.justify-content-end {
justify-content: end;
}
}
.delete-item {
@include padding($space-large);
button {
@include margin($zero);
}
}
}
.modal-enter,

View file

@ -21,10 +21,7 @@
border: 1px solid var(--color-border);
}
.margin-right-small {
margin-right: var(--space-small);
}
.display-flex {
.reports-option__wrap {
align-items: center;
display: flex;
}

View file

@ -6,12 +6,6 @@
}
.sidebar {
@include border-normal-right;
@include background-white;
@include full-height;
@include margin(0);
@include space-between-column;
width: $nav-bar-width;
z-index: 1024 - 1;
//logo
@ -22,26 +16,6 @@
}
}
.main-nav {
a {
border-radius: $space-smaller;
color: $color-gray;
font-size: $font-size-default;
font-weight: $font-weight-medium;
.wrap,
.child-icon {
&:hover {
color: $color-woot;
}
}
}
.active a .wrap {
color: $color-woot;
}
}
.nested {
a {
font-size: $font-size-small;
@ -83,34 +57,6 @@
}
}
.main-nav {
@include flex-weight(1);
@include scroll-on-hover;
padding: 0 $space-medium - $space-one;
a {
&::before {
margin-right: $space-slab;
}
}
.menu-title {
color: $color-gray;
font-size: $font-size-medium;
margin-top: $space-medium;
>span {
margin-left: $space-one;
}
}
}
.menu-title+ul>li>a {
@include padding($space-micro null);
color: $medium-gray;
line-height: $global-lineheight;
}
.hamburger--menu {
cursor: pointer;
display: none;

View file

@ -1,6 +1,6 @@
<template>
<div class="cw-accordion">
<button class="cw-accordion--title" @click="$emit('click')">
<button class="cw-accordion--title drag-handle" @click="$emit('click')">
<div class="cw-accordion--title-wrap">
<emoji-or-icon class="icon-or-emoji" :icon="icon" :emoji="emoji" />
<h5>
@ -10,12 +10,16 @@
<div class="button-icon--wrap">
<slot name="button" />
<div class="chevron-icon__wrap">
<i v-if="isOpen" class="ion-minus chevron-icon"></i>
<i v-else class="ion-plus chevron-icon"></i>
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
</div>
</button>
<div v-if="isOpen" class="cw-accordion--content">
<div
v-if="isOpen"
class="cw-accordion--content"
:class="{ compact: compact }"
>
<slot />
</div>
</div>
@ -33,6 +37,10 @@ export default {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
@ -58,10 +66,10 @@ export default {
}
.cw-accordion--title {
align-items: center;
background: var(--b-50);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
cursor: pointer;
background: var(--s-50);
border-bottom: 1px solid var(--s-100);
border-top: 1px solid var(--s-100);
cursor: grab;
display: flex;
justify-content: space-between;
margin: 0;
@ -106,5 +114,9 @@ export default {
.cw-accordion--content {
padding: var(--space-normal);
&.compact {
padding: 0;
}
}
</style>

View file

@ -1,15 +1,40 @@
<template>
l<template>
<div class="conversations-list-wrap">
<slot></slot>
<div class="chat-list__top">
<div class="chat-list__top" :class="{ filter__applied: hasAppliedFilters }">
<h1 class="page-title text-truncate" :title="pageTitle">
{{ pageTitle }}
</h1>
<chat-filter @statusFilterChange="updateStatusType" />
<div class="filter--actions">
<chat-filter
v-if="!hasAppliedFilters"
@statusFilterChange="updateStatusType"
/>
<woot-button
v-else
size="small"
variant="clear"
color-scheme="alert"
@click="resetAndFetchData"
>
{{ $t('FILTER.CLEAR_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-tooltip.top-end="$t('FILTER.TOOLTIP_LABEL')"
variant="clear"
color-scheme="secondary"
icon="filter"
size="small"
class="btn-filter"
@click="onToggleAdvanceFiltersModal"
>
</woot-button>
</div>
</div>
<chat-type-tabs
v-if="!isIframe"
v-if="!hasAppliedFilters && !isIframe"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@ -27,6 +52,7 @@
:active-label="label"
:team-id="teamId"
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
/>
@ -38,7 +64,7 @@
v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear"
size="expanded"
@click="fetchConversations"
@click="loadMoreConversations"
>
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button>
@ -54,6 +80,18 @@
{{ $t('CHAT_LIST.EOF') }}
</p>
</div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="onToggleAdvanceFiltersModal"
size="medium"
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:filter-types="advancedFilterTypes"
:on-close="onToggleAdvanceFiltersModal"
@applyFilter="onApplyFilter"
/>
</woot-modal>
</div>
</template>
@ -61,12 +99,16 @@
import { mapGetters } from 'vuex';
import ChatFilter from './widgets/conversation/ChatFilter';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
import ChatTypeTabs from './widgets/ChatTypeTabs';
import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
@ -77,6 +119,7 @@ export default {
ChatTypeTabs,
ConversationCard,
ChatFilter,
ConversationAdvancedFilter,
},
mixins: [timeMixin, conversationMixin, eventListenerMixins],
props: {
@ -92,11 +135,20 @@ export default {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
},
data() {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
attributeName: this.$t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
})),
};
},
computed: {
@ -110,10 +162,14 @@ export default {
currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
}),
isIframe() {
return window.self !== window.top;
},
hasAppliedFilters() {
return this.appliedFilters.length;
},
assigneeTabItems() {
return this.$t('CHAT_LIST.ASSIGNEE_TYPE_TABS').map(item => {
const count = this.conversationStats[item.COUNT_KEY] || 0;
@ -135,9 +191,17 @@ export default {
this.activeAssigneeTab
);
},
currentPageFilterKey() {
return this.hasAppliedFilters ? 'appliedFilters' : this.activeAssigneeTab;
},
currentFiltersPage() {
return this.$store.getters['conversationPage/getCurrentPageFilter'](
this.currentPageFilterKey
);
},
hasCurrentPageEndReached() {
return this.$store.getters['conversationPage/getHasEndReached'](
this.activeAssigneeTab
this.currentPageFilterKey
);
},
conversationFilters() {
@ -148,6 +212,9 @@ export default {
page: this.currentPage + 1,
labels: this.label ? [this.label] : undefined,
teamId: this.teamId ? this.teamId : undefined,
conversationType: this.conversationType
? this.conversationType
: undefined,
};
},
pageTitle() {
@ -160,10 +227,14 @@ export default {
if (this.label) {
return `#${this.label}`;
}
if (this.conversationType === 'mention') {
return this.$t('CHAT_LIST.MENTION_HEADING');
}
return this.$t('CHAT_LIST.TAB_HEADING');
},
conversationList() {
let conversationList = [];
if (!this.hasAppliedFilters) {
const filters = this.conversationFilters;
if (this.activeAssigneeTab === 'me') {
conversationList = [...this.mineChatsList(filters)];
@ -172,6 +243,9 @@ export default {
} else {
conversationList = [...this.allChatList(filters)];
}
} else {
conversationList = [...this.chatLists];
}
return conversationList;
},
@ -192,6 +266,9 @@ export default {
label() {
this.resetAndFetchData();
},
conversationType() {
this.resetAndFetchData();
},
},
mounted() {
this.$store.dispatch('setChatFilter', this.activeStatus);
@ -202,6 +279,17 @@ export default {
});
},
methods: {
onApplyFilter(payload) {
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' });
}
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onToggleAdvanceFiltersModal() {
this.showAdvancedFilters = !this.showAdvancedFilters;
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
@ -249,6 +337,7 @@ export default {
resetAndFetchData() {
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
this.$store.dispatch('clearConversationFilters');
this.fetchConversations();
},
fetchConversations() {
@ -256,6 +345,23 @@ export default {
.dispatch('fetchAllConversations', this.conversationFilters)
.then(() => this.$emit('conversation-load'));
},
loadMoreConversations() {
if (!this.hasAppliedFilters) {
this.fetchConversations();
} else {
this.fetchFilteredConversations(this.appliedFilters);
}
},
fetchFilteredConversations(payload) {
let page = this.currentFiltersPage + 1;
this.$store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
page,
})
.then(() => this.$emit('conversation-load'));
this.showAdvancedFilters = false;
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
bus.$emit('clearSearchInput');
@ -277,6 +383,7 @@ export default {
<style scoped lang="scss">
@import '~dashboard/assets/scss/woot';
.spinner {
margin-top: var(--space-normal);
margin-bottom: var(--space-normal);
@ -299,4 +406,17 @@ export default {
flex-basis: 46rem;
}
}
.filter--actions {
display: flex;
align-items: center;
}
.btn-filter {
margin: 0 var(--space-smaller);
}
.filter__applied {
padding: var(--space-slab) 0 !important;
border-bottom: 1px solid var(--color-border);
}
</style>

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