Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-05-21 23:28:22 +05:30
committed by GitHub
72 changed files with 1599 additions and 581 deletions
+3 -3
View File
@@ -73,15 +73,15 @@ jobs:
libvips libvips
- run: - run:
name: Install RVM and Ruby 3.3.3 name: Install RVM and Ruby 3.4.4
command: | command: |
sudo apt-get install -y gpg sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable \curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
rvm install "3.3.3" rvm install "3.4.4"
rvm use 3.3.3 --default rvm use 3.4.4 --default
gem install bundler -v 2.5.16 gem install bundler -v 2.5.16
- run: - run:
+2 -2
View File
@@ -12,7 +12,7 @@ services:
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
USER_GID: '1000' USER_GID: '1000'
@@ -25,7 +25,7 @@ services:
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
USER_GID: '1000' USER_GID: '1000'
+179 -8
View File
@@ -1,7 +1,10 @@
require: plugins:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb - ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb - ./rubocop/custom_cop_location.rb
@@ -13,44 +16,61 @@ Metrics/ClassLength:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
- 'app/models/conversation.rb' - 'app/models/conversation.rb'
Metrics/MethodLength: Metrics/MethodLength:
Max: 19 Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/ExponentialNotation: Style/ExponentialNotation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false
Style/SymbolArray: Style/SymbolArray:
Enabled: false Enabled: false
Style/OpenStructUse: Style/OpenStructUse:
Enabled: false Enabled: false
Style/OptionalBooleanParameter: Style/OptionalBooleanParameter:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb' - 'app/dispatchers/dispatcher.rb'
Style/GlobalVars: Style/GlobalVars:
Exclude: Exclude:
- 'config/initializers/01_redis.rb' - 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb' - 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb' - 'lib/redis/alfred.rb'
- 'lib/global_config.rb' - 'lib/global_config.rb'
Style/ClassVars: Style/ClassVars:
Exclude: Exclude:
- 'app/services/email_templates/db_resolver_service.rb' - 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper: Lint/MissingSuper:
Exclude: Exclude:
- 'app/drops/base_drop.rb' - 'app/drops/base_drop.rb'
Lint/SymbolConversion: Lint/SymbolConversion:
Enabled: false Enabled: false
Lint/EmptyBlock: Lint/EmptyBlock:
Exclude: Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder' - 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant: Lint/OrAssignmentToConstant:
Exclude: Exclude:
- 'lib/redis/config.rb' - 'lib/redis/config.rb'
Metrics/BlockLength: Metrics/BlockLength:
Max: 30 Max: 30
Exclude: Exclude:
@@ -58,10 +78,16 @@ Metrics/BlockLength:
- '**/routes.rb' - '**/routes.rb'
- 'config/environments/*' - 'config/environments/*'
- db/schema.rb - db/schema.rb
Metrics/ModuleLength: Metrics/ModuleLength:
Exclude: Exclude:
- lib/seeders/message_seeder.rb - lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb - spec/support/slack_stubs.rb
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -71,74 +97,101 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb' - 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb' - 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb' - 'app/controllers/survey/responses_controller.rb'
Rails/FindEach: Rails/FindEach:
Enabled: true Enabled: true
Include: Include:
- 'app/**/*.rb' - 'app/**/*.rb'
Rails/CompactBlank: Rails/CompactBlank:
Enabled: false Enabled: false
Rails/EnvironmentVariableAccess: Rails/EnvironmentVariableAccess:
Enabled: false Enabled: false
Rails/TimeZoneAssignment: Rails/TimeZoneAssignment:
Enabled: false Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo: Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false Enabled: false
Rails/InverseOf:
Exclude:
- enterprise/app/models/captain/assistant.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- app/models/canned_response.rb
- app/models/telegram_bot.rb
- enterprise/app/models/captain_inbox.rb
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Style/ClassAndModuleChildren: Style/ClassAndModuleChildren:
EnforcedStyle: compact EnforcedStyle: compact
Exclude: Exclude:
- 'config/application.rb' - 'config/application.rb'
- 'config/initializers/monkey_patches/*' - 'config/initializers/monkey_patches/*'
Style/MapToHash: Style/MapToHash:
Enabled: false Enabled: false
Style/HashSyntax: Style/HashSyntax:
Enabled: true Enabled: true
EnforcedStyle: no_mixed_keys EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never EnforcedShorthandSyntax: never
RSpec/NestedGroups: RSpec/NestedGroups:
Enabled: true Enabled: true
Max: 4 Max: 4
RSpec/MessageSpies: RSpec/MessageSpies:
Enabled: false Enabled: false
RSpec/StubbedMock: RSpec/StubbedMock:
Enabled: false Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber: Naming/VariableNumber:
Enabled: false Enabled: false
Naming/MemoizedInstanceVariableName: Naming/MemoizedInstanceVariableName:
Exclude: Exclude:
- 'app/models/message.rb' - 'app/models/message.rb'
Style/GuardClause: Style/GuardClause:
Exclude: Exclude:
- 'app/builders/account_builder.rb' - 'app/builders/account_builder.rb'
- 'app/models/attachment.rb' - 'app/models/attachment.rb'
- 'app/models/message.rb' - 'app/models/message.rb'
Metrics/AbcSize: Metrics/AbcSize:
Max: 26 Max: 26
Exclude: Exclude:
- 'app/controllers/concerns/auth_helper.rb' - 'app/controllers/concerns/auth_helper.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb' - 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb' - 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb' - 'app/models/telegram_bot.rb'
Rails/RenderInline: Rails/RenderInline:
Exclude: Exclude:
- 'app/controllers/swagger_controller.rb' - 'app/controllers/swagger_controller.rb'
Rails/ThreeStateBooleanColumn: Rails/ThreeStateBooleanColumn:
Exclude: Exclude:
- 'db/migrate/20230503101201_create_sla_policies.rb' - 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet: RSpec/IndexedLet:
Enabled: false Enabled: false
RSpec/NamedSubject: RSpec/NamedSubject:
Enabled: false Enabled: false
# we should bring this down # we should bring this down
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 7 Max: 7
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 14 Max: 14
@@ -166,3 +219,121 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20230426130150_init_schema.rb'
FactoryBot/SyntaxMethods:
Enabled: false
# Disable new rules causing errors
Layout/LeadingCommentSpace:
Enabled: false
Style/ReturnNilInPredicateMethodDefinition:
Enabled: false
Style/RedundantParentheses:
Enabled: false
Performance/StringIdentifierArgument:
Enabled: false
Layout/EmptyLinesAroundExceptionHandlingKeywords:
Enabled: false
Lint/LiteralAsCondition:
Enabled: false
Style/RedundantReturn:
Enabled: false
Layout/SpaceAroundOperators:
Enabled: false
Rails/EnvLocal:
Enabled: false
Rails/WhereRange:
Enabled: false
Lint/UselessConstantScoping:
Enabled: false
Style/MultipleComparison:
Enabled: false
Bundler/OrderedGems:
Enabled: false
RSpec/ExampleWording:
Enabled: false
RSpec/ReceiveMessages:
Enabled: false
FactoryBot/AssociationStyle:
Enabled: false
Rails/EnumSyntax:
Enabled: false
Lint/RedundantTypeConversion:
Enabled: false
# Additional rules to disable
Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Style/SafeNavigationChainLength:
Enabled: false
Lint/SafeNavigationConsistency:
Enabled: false
Lint/CopDirectiveSyntax:
Enabled: false
# Final set of rules to disable
FactoryBot/ExcessiveCreateList:
Enabled: false
RSpec/MissingExpectationTargetMethod:
Enabled: false
Performance/InefficientHashSearch:
Enabled: false
Style/RedundantSelfAssignmentBranch:
Enabled: false
Style/YAMLFileRead:
Enabled: false
Layout/ExtraSpacing:
Enabled: false
Style/RedundantFilterChain:
Enabled: false
Performance/MapMethodChain:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
Style/SuperArguments:
Enabled: false
# Final remaining rules to disable
Rails/Delegate:
Enabled: false
Style/CaseLikeIf:
Enabled: false
FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
+1 -1
View File
@@ -1 +1 @@
3.3.3 3.4.4
+3 -2
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.3.3' ruby '3.4.4'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors' gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.0.8.4' gem 'rails', '~> 7.1'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@@ -237,6 +237,7 @@ group :development, :test do
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump' gem 'seed_dump'
gem 'shoulda-matchers' gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false gem 'simplecov', '0.17.1', require: false
+160 -140
View File
@@ -25,76 +25,89 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8.7) actioncable (7.1.5.1)
actionpack (= 7.0.8.7) actionpack (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.7) zeitwerk (~> 2.6)
actionpack (= 7.0.8.7) actionmailbox (7.1.5.1)
activejob (= 7.0.8.7) actionpack (= 7.1.5.1)
activerecord (= 7.0.8.7) activejob (= 7.1.5.1)
activestorage (= 7.0.8.7) activerecord (= 7.1.5.1)
activesupport (= 7.0.8.7) activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.8.7) actionmailer (7.1.5.1)
actionpack (= 7.0.8.7) actionpack (= 7.1.5.1)
actionview (= 7.0.8.7) actionview (= 7.1.5.1)
activejob (= 7.0.8.7) activejob (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
actionpack (7.0.8.7) actionpack (7.1.5.1)
actionview (= 7.0.8.7) actionview (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
rack (~> 2.0, >= 2.2.4) nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.6)
actiontext (7.0.8.7) actiontext (7.1.5.1)
actionpack (= 7.0.8.7) actionpack (= 7.1.5.1)
activerecord (= 7.0.8.7) activerecord (= 7.1.5.1)
activestorage (= 7.0.8.7) activestorage (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8.7) actionview (7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.11)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.0.8.7) activejob (7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8.7) activemodel (7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
activerecord (7.0.8.7) activerecord (7.1.5.1)
activemodel (= 7.0.8.7) activemodel (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
activerecord-import (1.4.1) timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.0.8.7) activestorage (7.1.5.1)
actionpack (= 7.0.8.7) actionpack (= 7.1.5.1)
activejob (= 7.0.8.7) activejob (= 7.1.5.1)
activerecord (= 7.0.8.7) activerecord (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) activesupport (7.1.5.1)
activesupport (7.0.8.7) base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0)
acts-as-taggable-on (9.0.1) acts-as-taggable-on (12.0.0)
activerecord (>= 6.0, < 7.1) activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
@@ -116,7 +129,7 @@ GEM
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.3)
attr_extras (7.1.0) attr_extras (7.1.0)
audited (5.4.1) audited (5.4.1)
activerecord (>= 5.0, < 7.7) activerecord (>= 5.0, < 7.7)
@@ -142,14 +155,15 @@ GEM
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bigdecimal (3.1.8) benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.16.0)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.4.1) brakeman (5.4.1)
browser (5.3.1) browser (5.3.1)
builder (3.3.0) builder (3.3.0)
bullet (7.0.7) bullet (8.0.7)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundle-audit (0.1.0) bundle-audit (0.1.0)
@@ -161,8 +175,8 @@ GEM
climate_control (1.2.0) climate_control (1.2.0)
coderay (1.1.3) coderay (1.1.3)
commonmarker (0.23.10) commonmarker (0.23.10)
concurrent-ruby (1.3.4) concurrent-ruby (1.3.5)
connection_pool (2.4.1) connection_pool (2.5.3)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
rexml rexml
@@ -176,16 +190,10 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
datadog-ci (0.8.3)
msgpack
date (3.4.1) date (3.4.1)
ddtrace (1.23.2) ddtrace (0.48.0)
datadog-ci (~> 0.8.1) ffi (~> 1.0)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
msgpack msgpack
debase-ruby_core_source (3.3.1)
debug (1.8.0) debug (1.8.0)
irb (>= 1.5.0) irb (>= 1.5.0)
reline (>= 0.3.1) reline (>= 0.3.1)
@@ -196,10 +204,10 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise_token_auth (1.2.3) devise_token_auth (1.2.5)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2) rails (>= 4.2.0, < 8.1)
diff-lcs (1.5.1) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
@@ -212,6 +220,7 @@ GEM
railties (>= 6.1) railties (>= 6.1)
down (5.4.0) down (5.4.0)
addressable (~> 2.8) addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0) dry-cli (1.1.0)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
regexp_parser (~> 2.2) regexp_parser (~> 2.2)
@@ -254,7 +263,10 @@ GEM
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
ffi (1.16.3) ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@@ -315,16 +327,13 @@ GEM
google-cloud-translate-v3 (0.10.0) google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a) gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-protobuf (3.25.5) google-protobuf (3.25.7)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
googleapis-common-protos (1.6.0) googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7) googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41) grpc (~> 1.41)
googleapis-common-protos-types (1.14.0) googleapis-common-protos-types (1.20.0)
google-protobuf (~> 3.18) google-protobuf (>= 3.18, < 5.a)
googleauth (1.11.2) googleauth (1.11.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1) google-cloud-env (~> 2.1)
@@ -334,17 +343,17 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
groupdate (6.2.1) groupdate (6.2.1)
activesupport (>= 5.2) activesupport (>= 5.2)
grpc (1.62.0) grpc (1.72.0)
google-protobuf (~> 3.25) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-arm64-darwin) grpc (1.72.0-arm64-darwin)
google-protobuf (~> 3.25) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-darwin) grpc (1.72.0-x86_64-darwin)
google-protobuf (~> 3.25) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-linux) grpc (1.72.0-x86_64-linux)
google-protobuf (~> 3.25) google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0) googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1) haikunator (1.1.1)
hairtrigger (1.0.0) hairtrigger (1.0.0)
@@ -370,7 +379,7 @@ GEM
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.6) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
@@ -388,7 +397,7 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
json (2.6.3) json (2.12.0)
json_refs (0.1.8) json_refs (0.1.8)
hana hana
json_schemer (0.2.24) json_schemer (0.2.24)
@@ -423,21 +432,13 @@ GEM
faraday-multipart faraday-multipart
json (>= 1.8) json (>= 1.8)
rexml rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
launchy (>= 2.2, < 3) launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0) line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0) liquid (5.4.0)
listen (3.8.0) listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3) rb-fsevent (~> 0.10, >= 0.10.3)
@@ -445,7 +446,7 @@ GEM
llhttp-ffi (0.4.0) llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0) ffi-compiler (~> 1.0)
rake (~> 13.0) rake (~> 13.0)
logger (1.6.0) logger (1.7.0)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
@@ -471,10 +472,10 @@ GEM
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.8)
minitest (5.25.4) minitest (5.25.5)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.7.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
@@ -545,14 +546,16 @@ GEM
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.1) ostruct (0.6.1)
parallel (1.23.0) parallel (1.27.0)
parser (3.2.2.1) parser (3.3.8.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc
pg (1.5.3) pg (1.5.3)
pg_search (2.3.6) pg_search (2.3.6)
activerecord (>= 5.2) activerecord (>= 5.2)
activesupport (>= 5.2) activesupport (>= 5.2)
pgvector (0.1.1) pgvector (0.1.1)
prism (1.4.0)
procore-sift (1.0.0) procore-sift (1.0.0)
activerecord (>= 6.1) activerecord (>= 6.1)
pry (0.14.2) pry (0.14.2)
@@ -567,7 +570,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.14) rack (2.2.15)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -581,23 +584,28 @@ GEM
rack (~> 2.2, >= 2.2.4) rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rails (7.0.8.7) rackup (1.0.1)
actioncable (= 7.0.8.7) rack (< 3)
actionmailbox (= 7.0.8.7) webrick
actionmailer (= 7.0.8.7) rails (7.1.5.1)
actionpack (= 7.0.8.7) actioncable (= 7.1.5.1)
actiontext (= 7.0.8.7) actionmailbox (= 7.1.5.1)
actionview (= 7.0.8.7) actionmailer (= 7.1.5.1)
activejob (= 7.0.8.7) actionpack (= 7.1.5.1)
activemodel (= 7.0.8.7) actiontext (= 7.1.5.1)
activerecord (= 7.0.8.7) actionview (= 7.1.5.1)
activestorage (= 7.0.8.7) activejob (= 7.1.5.1)
activesupport (= 7.0.8.7) activemodel (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8.7) railties (= 7.1.5.1)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -605,13 +613,14 @@ GEM
rails-html-sanitizer (1.6.1) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.7) railties (7.1.5.1)
actionpack (= 7.0.8.7) actionpack (= 7.1.5.1)
activesupport (= 7.0.8.7) activesupport (= 7.1.5.1)
method_source irb
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.5) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
@@ -623,7 +632,7 @@ GEM
connection_pool connection_pool
redis-namespace (1.10.0) redis-namespace (1.10.0)
redis (>= 4) redis (>= 4)
regexp_parser (2.8.0) regexp_parser (2.10.0)
reline (0.3.6) reline (0.3.6)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
@@ -643,7 +652,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.3.9) rexml (3.4.1)
rspec-core (3.13.0) rspec-core (3.13.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.2) rspec-expectations (3.13.2)
@@ -663,30 +672,36 @@ GEM
rspec-support (3.13.1) rspec-support (3.13.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2) rubocop (1.75.6)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.0.0) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rexml (>= 3.2.5, < 4.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.28.1) rubocop-ast (1.44.1)
parser (>= 3.2.1.0) parser (>= 3.3.7.2)
rubocop-capybara (2.18.0) prism (~> 1.4)
rubocop (~> 1.41) rubocop-factory_bot (2.27.1)
rubocop-performance (1.17.1) lint_roller (~> 1.1)
rubocop (>= 1.7.0, < 2.0) rubocop (~> 1.72, >= 1.72.1)
rubocop-ast (>= 0.4.0) rubocop-performance (1.25.0)
rubocop-rails (2.19.1) lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-rspec (2.21.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop (~> 1.33) rubocop-rspec (3.6.0)
rubocop-capybara (~> 2.17) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
ruby-openai (7.3.1) ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0) event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1) faraday (>= 1)
@@ -816,8 +831,10 @@ GEM
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (2.4.2) unicode-display_width (3.1.4)
uniform_notifier (1.16.0) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3) uri (1.0.3)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
@@ -845,7 +862,9 @@ GEM
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.6) webrick (1.9.1)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
@@ -951,7 +970,7 @@ DEPENDENCIES
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.0.8.4) rails (~> 7.1)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
@@ -960,6 +979,7 @@ DEPENDENCIES
rspec-rails (>= 6.1.5) rspec-rails (>= 6.1.5)
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rubocop-factory_bot
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
@@ -997,7 +1017,7 @@ DEPENDENCIES
working_hours working_hours
RUBY VERSION RUBY VERSION
ruby 3.3.3p89 ruby 3.4.4p34
BUNDLED WITH BUNDLED WITH
2.5.16 2.5.16
+1 -25
View File
@@ -32,14 +32,7 @@ class AccountBuilder
end end
def validate_email def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked? Account::SignUpEmailValidationService.new(@email).perform
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
true
else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
end
end end
def validate_user def validate_user
@@ -81,21 +74,4 @@ class AccountBuilder
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!
end end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end end
+4 -2
View File
@@ -59,11 +59,13 @@ class ContactInboxBuilder
end end
def create_contact_inbox def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!( attrs = {
contact_id: @contact.id, contact_id: @contact.id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_id source_id: @source_id
) }
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}") Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox update_old_contact_inbox
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
Current.account Current.account
).perform ).perform
# Only allow conversations from inboxes the user has access to
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
@conversations = conversations.order(last_activity_at: :desc).limit(20) @conversations = conversations.order(last_activity_at: :desc).limit(20)
end end
end end
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def sign_up_user def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user create_account_for_user
token = @resource.send(:set_reset_password_token) token = @resource.send(:set_reset_password_token)
@@ -53,9 +53,11 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
).first ).first
end end
def validate_business_account? def validate_signup_email_is_business_domain?
# return true if the user is a business account, false if it is a gmail account # return true if the user is a business account, false if it is a blocked domain account
auth_hash['info']['email'].downcase.exclude?('@gmail.com') Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform
rescue CustomExceptions::Account::InvalidEmail
false
end end
def create_account_for_user def create_account_for_user
+4 -1
View File
@@ -88,7 +88,10 @@ class ConversationFinder
def find_conversation_by_inbox def find_conversation_by_inbox
@conversations = current_account.conversations @conversations = current_account.conversations
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
return unless params[:inbox_id]
@conversations = @conversations.where(inbox_id: @inbox_ids)
end end
def find_all_conversations def find_all_conversations
@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</template>
@@ -7,6 +7,7 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
/> />
<template #after> <template #after>
<transition <div
enter-active-class="overflow-hidden transition-all duration-300 ease-out" class="transition-all duration-500 ease-in-out grid overflow-hidden"
leave-active-class="overflow-hidden transition-all duration-300 ease-in" :class="
enter-from-class="overflow-hidden opacity-0 max-h-0" isExpanded
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]" ? 'grid-rows-[1fr] opacity-100'
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]" : 'grid-rows-[0fr] opacity-0'
leave-to-class="overflow-hidden opacity-0 max-h-0" "
> >
<div v-show="isExpanded" class="w-full"> <div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong"> <div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm <ContactsForm
ref="contactsFormRef" ref="contactsFormRef"
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
/> />
</div> </div>
</div> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</transition> </div>
</template> </template>
</CardLayout> </CardLayout>
</template> </template>
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
ref="dialogRef" ref="dialogRef"
type="alert" type="alert"
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')" :title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
:description=" :description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
contactName: props.selectedContact.name,
})
"
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')" :confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
@confirm="handleDialogConfirm" @confirm="handleDialogConfirm"
/> />
@@ -117,7 +117,7 @@ const STYLE_CONFIG = {
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8', 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent', 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent', link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
}, },
amber: { amber: {
solid: solid:
@@ -0,0 +1,21 @@
<script setup>
import CopilotHeader from './CopilotHeader.vue';
</script>
<template>
<Story
title="Captain/Copilot/CopilotHeader"
:layout="{ type: 'grid', width: '800px' }"
>
<!-- Default State -->
<Variant title="Default State">
<CopilotHeader />
</Variant>
<!-- With New Conversation Button -->
<Variant title="With New Conversation Button">
<!-- eslint-disable-next-line vue/prefer-true-attribute-shorthand -->
<CopilotHeader :has-messages="true" />
</Variant>
</Story>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from '../button/Button.vue';
defineProps({
hasMessages: {
type: Boolean,
default: false,
},
});
defineEmits(['reset', 'close']);
</script>
<template>
<div
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CAPTAIN.COPILOT.TITLE') }}
</span>
<div class="flex items-center">
<Button
v-if="hasMessages"
icon="i-lucide-plus"
ghost
sm
@click="$emit('reset')"
/>
<Button icon="i-lucide-x" ghost sm @click="$emit('close')" />
</div>
</div>
</div>
</template>
@@ -13,19 +13,16 @@ const sendMessage = () => {
</script> </script>
<template> <template>
<form <form class="relative" @submit.prevent="sendMessage">
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<input <input
v-model="message" v-model="message"
type="text" type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')" :placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm" class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-n-slate-11 text-sm border border-n-weak rounded-lg focus:outline-none focus:ring-1 focus:ring-n-blue-11 focus:border-n-blue-11"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
/> />
<button <button
class="h-auto w-12 flex items-center justify-center text-n-slate-11" class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
type="submit" type="submit"
> >
<i class="i-ph-arrow-up" /> <i class="i-ph-arrow-up" />
@@ -0,0 +1,25 @@
<script setup>
import Icon from '../../components-next/icon/Icon.vue';
defineProps({
content: {
type: String,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-lg bg-n-background/50 border border-n-weak hover:bg-n-background/80 transition-colors duration-200"
>
<div class="flex items-start gap-2">
<Icon
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-11">
{{ content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
const messages = [
{
id: 1,
content: 'Analyzing the user query',
reasoning: 'Breaking down the request into actionable steps',
},
{
id: 2,
content: 'Searching codebase',
reasoning: 'Looking for relevant files and functions',
},
{
id: 3,
content: 'Generating response',
reasoning: 'Composing a helpful and accurate answer',
},
];
</script>
<template>
<Story title="Captain/Copilot/CopilotThinkingGroup" group="components">
<Variant title="Default">
<CopilotThinkingGroup :messages="messages" />
</Variant>
<Variant title="With Default Collapsed">
<!-- eslint-disable-next-line -->
<CopilotThinkingGroup :messages="messages" :default-collapsed="true" />
</Variant>
</Story>
</template>
@@ -0,0 +1,61 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from '../icon/Icon.vue';
import CopilotThinkingBlock from './CopilotThinkingBlock.vue';
const props = defineProps({
messages: { type: Array, required: true },
defaultCollapsed: { type: Boolean, default: false },
});
const { t } = useI18n();
const isExpanded = ref(!props.defaultCollapsed);
const thinkingCount = computed(() => props.messages.length);
watch(
() => props.defaultCollapsed,
newValue => {
if (newValue) {
isExpanded.value = false;
}
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<button
class="group flex items-center gap-2 text-xs text-n-slate-10 hover:text-n-slate-11 transition-colors duration-200 -ml-3"
@click="isExpanded = !isExpanded"
>
<Icon
:icon="isExpanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
<span class="flex items-center gap-2">
{{ t('CAPTAIN.COPILOT.SHOW_STEPS') }}
<span
class="inline-flex items-center justify-center h-4 min-w-4 px-1 text-xs font-medium rounded-full bg-n-solid-3 text-n-slate-11"
>
{{ thinkingCount }}
</span>
</span>
</button>
<div
v-show="isExpanded"
class="space-y-3 transition-all duration-200"
:class="{
'opacity-100': isExpanded,
'opacity-0 max-h-0 overflow-hidden': !isExpanded,
}"
>
<CopilotThinkingBlock
v-for="message in messages"
:key="message.id"
:content="message.content"
:reasoning="message.reasoning"
/>
</div>
</div>
</template>
@@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { import {
DropdownContainer, DropdownContainer,
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
const currentAccountId = useMapGetter('getCurrentAccountId'); const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline'); const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
const { isImpersonating } = useImpersonation();
const { AVAILABILITY_STATUS_KEYS } = wootConstants; const { AVAILABILITY_STATUS_KEYS } = wootConstants;
const statusList = computed(() => { const statusList = computed(() => {
return [ return [
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
}); });
function changeAvailabilityStatus(availability) { function changeAvailabilityStatus(availability) {
if (isImpersonating.value) {
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
return;
}
try { try {
store.dispatch('updateAvailability', { store.dispatch('updateAvailability', {
availability, availability,
@@ -1,6 +1,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue'; import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -20,6 +21,10 @@ export default {
AvailabilityStatusBadge, AvailabilityStatusBadge,
NextButton, NextButton,
}, },
setup() {
const { isImpersonating } = useImpersonation();
return { isImpersonating };
},
data() { data() {
return { return {
isStatusMenuOpened: false, isStatusMenuOpened: false,
@@ -73,6 +78,13 @@ export default {
}); });
}, },
changeAvailabilityStatus(availability) { changeAvailabilityStatus(availability) {
if (this.isImpersonating) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
);
return;
}
if (this.isUpdating) { if (this.isUpdating) {
return; return;
} }
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useImpersonation } from '../useImpersonation';
vi.mock('shared/helpers/sessionStorage', () => ({
__esModule: true,
default: {
get: vi.fn(),
set: vi.fn(),
},
}));
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
describe('useImpersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if impersonation flag is set in session storage', () => {
SessionStorage.get.mockReturnValue(true);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(true);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
it('should return false if impersonation flag is not set in session storage', () => {
SessionStorage.get.mockReturnValue(false);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(false);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
});
@@ -0,0 +1,10 @@
import { computed } from 'vue';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
export function useImpersonation() {
const isImpersonating = computed(() => {
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
});
return { isImpersonating };
}
@@ -0,0 +1,3 @@
export const SESSION_STORAGE_KEYS = {
IMPERSONATION_USER: 'impersonationUser',
};
@@ -59,6 +59,11 @@
"ERROR_MESSAGE": "Could not update bot. Please try again." "ERROR_MESSAGE": "Could not update bot. Please try again."
} }
}, },
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard"
},
"FORM": { "FORM": {
"AVATAR": { "AVATAR": {
"LABEL": "Bot avatar" "LABEL": "Bot avatar"
@@ -458,6 +458,10 @@
"PLACEHOLDER": "Add Twitter" "PLACEHOLDER": "Add Twitter"
} }
} }
},
"DELETE_CONTACT": {
"MESSAGE": "This action is permanent and irreversible.",
"BUTTON": "Delete now"
} }
}, },
"DETAILS": { "DETAILS": {
@@ -467,7 +471,7 @@
"DELETE_CONTACT": "Delete contact", "DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": { "DELETE_DIALOG": {
"TITLE": "Confirm Deletion", "TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?", "DESCRIPTION": "Are you sure you want to delete this contact?",
"CONFIRM": "Yes, Delete", "CONFIRM": "Yes, Delete",
"API": { "API": {
"SUCCESS_MESSAGE": "Contact deleted successfully", "SUCCESS_MESSAGE": "Contact deleted successfully",
@@ -327,12 +327,14 @@
"NAME": "Captain", "NAME": "Captain",
"HEADER_KNOW_MORE": "Know more", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"TITLE": "Copilot",
"SEND_MESSAGE": "Send message...", "SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.", "EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
"YOU": "You", "YOU": "You",
"USE": "Use this", "USE": "Use this",
"RESET": "Reset", "RESET": "Reset",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant", "SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": { "PROMPTS": {
"SUMMARIZE": { "SUMMARIZE": {
@@ -185,7 +185,8 @@
"OFFLINE": "Offline" "OFFLINE": "Offline"
}, },
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully", "SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again" "SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
}, },
"EMAIL": { "EMAIL": {
"LABEL": "Your email address", "LABEL": "Your email address",
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators'; import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useToggle } from '@vueuse/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
const props = defineProps({ const props = defineProps({
type: { type: {
@@ -42,6 +45,9 @@ const formState = reactive({
botAvatarUrl: '', botAvatarUrl: '',
}); });
const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref('');
const v$ = useVuelidate( const v$ = useVuelidate(
{ {
botName: { botName: {
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
: uiFlags.value.isUpdating : uiFlags.value.isUpdating
); );
const dialogTitle = computed(() => const dialogTitle = computed(() => {
props.type === MODAL_TYPES.CREATE if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
}
return props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE') ? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE') : t('AGENT_BOTS.EDIT.TITLE');
); });
const dialogDescription = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
}
return '';
});
const confirmButtonLabel = computed(() => const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE props.type === MODAL_TYPES.CREATE
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : '' v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
); );
const showAccessTokenInput = computed(
() =>
showAccessToken.value ||
props.type === MODAL_TYPES.EDIT ||
accessToken.value
);
const resetForm = () => { const resetForm = () => {
Object.assign(formState, { Object.assign(formState, {
botName: '', botName: '',
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
const handleSubmit = async () => { const handleSubmit = async () => {
v$.value.$touch(); v$.value.$touch();
if (v$.value.$invalid) return; if (v$.value.$invalid) return;
if (showAccessToken.value) return;
const botData = { const botData = {
name: formState.botName, name: formState.botName,
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
? botData ? botData
: { id: props.selectedBot.id, data: botData }; : { id: props.selectedBot.id, data: botData };
await store.dispatch( const response = await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`, `agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload actionPayload
); );
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'); : t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey); useAlert(alertKey);
dialogRef.value.close(); // Show access token after creation
if (isCreate) {
const { access_token: responseAccessToken, id } = response || {};
if (id && responseAccessToken) {
accessToken.value = responseAccessToken;
toggleAccessToken(true);
} else {
accessToken.value = '';
dialogRef.value.close();
}
} else {
dialogRef.value.close();
}
resetForm(); resetForm();
} catch (error) { } catch (error) {
const errorKey = isCreate const errorKey = isCreate
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
const initializeForm = () => { const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) { if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } = const {
props.selectedBot; name,
description,
outgoing_url: botUrl,
thumbnail,
bot_config: botConfig,
access_token: botAccessToken,
} = props.selectedBot;
formState.botName = name || ''; formState.botName = name || '';
formState.botDescription = description || ''; formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || ''; formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || ''; formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken;
}
} else { } else {
resetForm(); resetForm();
} }
}; };
const onCopyToken = async value => {
await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = '';
toggleAccessToken(false);
};
const onClickClose = () => {
closeModal();
dialogRef.value.close();
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true }); watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef }); defineExpose({ dialogRef });
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
ref="dialogRef" ref="dialogRef"
type="edit" type="edit"
:title="dialogTitle" :title="dialogTitle"
:description="dialogDescription"
:show-cancel-button="false" :show-cancel-button="false"
:show-confirm-button="false" :show-confirm-button="false"
@close="v$.$reset()" @close="closeModal"
> >
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit"> <form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start"> <div
<span class="mb-2 text-sm font-medium text-n-slate-12"> v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }} class="flex flex-col gap-4"
</span> >
<Avatar <div class="mb-2 flex flex-col items-start">
:src="formState.botAvatarUrl" <span class="mb-2 text-sm font-medium text-n-slate-12">
:name="formState.botName" {{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
:size="68" </span>
allow-upload <Avatar
@upload="handleImageUpload" :src="formState.botAvatarUrl"
@delete="handleAvatarDelete" :name="formState.botName"
:size="68"
allow-upload
icon-name="i-lucide-bot-message-square"
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
id="bot-name"
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
id="bot-description"
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
id="bot-url"
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/> />
</div> </div>
<Input <div v-if="showAccessTokenInput" class="flex flex-col gap-1">
v-model="formState.botName" <label
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')" v-if="type === MODAL_TYPES.EDIT"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')" class="mb-0.5 text-sm font-medium text-n-slate-12"
:message="botNameError" >
:message-type="botNameError ? 'error' : 'info'" {{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
@blur="v$.botName.$touch()" </label>
/> <AccessToken :value="accessToken" @on-copy="onCopyToken" />
</div>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2"> <div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton <NextButton
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
slate slate
type="reset" type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')" :label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()" @click="onClickClose()"
/> />
<NextButton <NextButton
v-if="!showAccessToken"
type="submit" type="submit"
data-testid="label-submit" data-testid="label-submit"
:label="confirmButtonLabel" :label="confirmButtonLabel"
@@ -39,6 +39,7 @@ const onClick = () => {
<template #masked> <template #masked>
<button <button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5" class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button"
@click="toggleMasked" @click="toggleMasked"
> >
<fluent-icon :icon="maskIcon" :size="16" /> <fluent-icon :icon="maskIcon" :size="16" />
@@ -46,7 +47,7 @@ const onClick = () => {
</template> </template>
</woot-input> </woot-input>
<FormButton <FormButton
type="submit" type="button"
size="large" size="large"
icon="text-copy" icon="text-copy"
variant="outline" variant="outline"
+11 -2
View File
@@ -2,6 +2,8 @@ import types from '../mutation-types';
import authAPI from '../../api/auth'; import authAPI from '../../api/auth';
import { setUser, clearCookiesOnLogout } from '../utils/api'; import { setUser, clearCookiesOnLogout } from '../utils/api';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
const initialState = { const initialState = {
currentUser: { currentUser: {
@@ -145,8 +147,15 @@ export const actions = {
updateUISettings: async ({ commit }, params) => { updateUISettings: async ({ commit }, params) => {
try { try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params); commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data); const isImpersonating = SessionStorage.get(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
if (!isImpersonating) {
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
}
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays'; import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { import {
ANALYTICS_IDENTITY, ANALYTICS_IDENTITY,
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES); LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
}; };
export const clearSessionStorageOnLogout = () => {
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
};
export const deleteIndexedDBOnLogout = async () => { export const deleteIndexedDBOnLogout = async () => {
let dbs = []; let dbs = [];
try { try {
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
emitter.emit(ANALYTICS_RESET); emitter.emit(ANALYTICS_RESET);
clearBrowserSessionCookies(); clearBrowserSessionCookies();
clearLocalStorageOnLogout(); clearLocalStorageOnLogout();
clearSessionStorageOnLogout();
const globalConfig = window.globalConfig || {}; const globalConfig = window.globalConfig || {};
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/'; const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
window.location = logoutRedirectLink; window.location = logoutRedirectLink;
@@ -0,0 +1,26 @@
export default {
clearAll() {
window.sessionStorage.clear();
},
get(key) {
try {
const value = window.sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
return window.sessionStorage.getItem(key);
}
},
set(key, value) {
if (typeof value === 'object') {
window.sessionStorage.setItem(key, JSON.stringify(value));
} else {
window.sessionStorage.setItem(key, value);
}
},
remove(key) {
window.sessionStorage.removeItem(key);
},
};
@@ -0,0 +1,137 @@
import SessionStorage from '../sessionStorage';
// Mocking sessionStorage
const sessionStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: key => delete store[key],
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
describe('SessionStorage utility', () => {
beforeEach(() => {
sessionStorage.clear();
});
describe('clearAll method', () => {
it('should clear all items from sessionStorage', () => {
sessionStorage.setItem('testKey1', 'testValue1');
sessionStorage.setItem('testKey2', 'testValue2');
SessionStorage.clearAll();
expect(sessionStorage.getItem('testKey1')).toBeNull();
expect(sessionStorage.getItem('testKey2')).toBeNull();
});
});
describe('get method', () => {
it('should retrieve and parse JSON values correctly', () => {
const testObject = { a: 1, b: 'test' };
sessionStorage.setItem('testKey', JSON.stringify(testObject));
expect(SessionStorage.get('testKey')).toEqual(testObject);
});
it('should return null for non-existent keys', () => {
expect(SessionStorage.get('nonExistentKey')).toBeNull();
});
it('should handle non-JSON values by returning the raw value', () => {
sessionStorage.setItem('testKey', 'plain string value');
expect(SessionStorage.get('testKey')).toBe('plain string value');
});
it('should handle malformed JSON gracefully', () => {
sessionStorage.setItem('testKey', '{malformed:json}');
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
});
});
describe('set method', () => {
it('should store object values as JSON strings', () => {
const testObject = { a: 1, b: 'test' };
SessionStorage.set('testKey', testObject);
expect(sessionStorage.getItem('testKey')).toBe(
JSON.stringify(testObject)
);
});
it('should store primitive values directly', () => {
SessionStorage.set('stringKey', 'test string');
expect(sessionStorage.getItem('stringKey')).toBe('test string');
SessionStorage.set('numberKey', 42);
expect(sessionStorage.getItem('numberKey')).toBe('42');
SessionStorage.set('booleanKey', true);
expect(sessionStorage.getItem('booleanKey')).toBe('true');
});
it('should handle null values', () => {
SessionStorage.set('nullKey', null);
expect(sessionStorage.getItem('nullKey')).toBe('null');
expect(SessionStorage.get('nullKey')).toBeNull();
});
it('should handle undefined values', () => {
SessionStorage.set('undefinedKey', undefined);
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
});
});
describe('remove method', () => {
it('should remove an item from sessionStorage', () => {
SessionStorage.set('testKey', 'testValue');
expect(SessionStorage.get('testKey')).toBe('testValue');
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should do nothing when removing a non-existent key', () => {
expect(() => {
SessionStorage.remove('nonExistentKey');
}).not.toThrow();
});
});
describe('Integration of methods', () => {
it('should set, get, and remove values correctly', () => {
SessionStorage.set('testKey', { value: 'test' });
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
SessionStorage.remove('testKey');
expect(SessionStorage.get('testKey')).toBeNull();
});
it('should correctly handle impersonation flag (common use case)', () => {
SessionStorage.set('impersonationUser', true);
expect(SessionStorage.get('impersonationUser')).toBe(true);
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
SessionStorage.remove('impersonationUser');
expect(SessionStorage.get('impersonationUser')).toBeNull();
});
});
});
+13 -1
View File
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { required, email } from '@vuelidate/validators'; import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
// mixins // mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin'; import globalConfigMixin from 'shared/mixins/globalConfigMixin';
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY', 'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
}; };
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
export default { export default {
components: { components: {
FormInput, FormInput,
@@ -112,6 +115,14 @@ export default {
this.loginApi.message = message; this.loginApi.message = message;
useAlert(this.loginApi.message); useAlert(this.loginApi.message);
}, },
handleImpersonation() {
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
const urlParams = new URLSearchParams(window.location.search);
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
if (impersonation) {
SessionStorage.set(SESSION_STORAGE_KEYS.IMPERSONATION_USER, true);
}
},
submitLogin() { submitLogin() {
this.loginApi.hasErrored = false; this.loginApi.hasErrored = false;
this.loginApi.showLoading = true; this.loginApi.showLoading = true;
@@ -128,6 +139,7 @@ export default {
login(credentials) login(credentials)
.then(() => { .then(() => {
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE')); this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
}) })
.catch(response => { .catch(response => {
+1 -1
View File
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
end end
def in_reply_to_matches?(in_reply_to) def in_reply_to_matches?(in_reply_to)
Array.wrap(in_reply_to).any? { _1.match?(CONVERSATION_MESSAGE_ID_PATTERN) } Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
end end
# checks if follow this pattern send it to reply_mailbox # checks if follow this pattern send it to reply_mailbox
+2 -2
View File
@@ -97,8 +97,8 @@ class Account < ApplicationRecord
has_one_attached :contacts_export has_one_attached :contacts_export
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum status: { active: 0, suspended: 1 } enum :status, { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") } scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
@@ -20,6 +20,10 @@ module SsoAuthenticatable
"#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}" "#{ENV.fetch('FRONTEND_URL', nil)}/app/login?email=#{encoded_email}&sso_auth_token=#{generate_sso_auth_token}"
end end
def generate_sso_link_with_impersonation
"#{generate_sso_link}&impersonation=true"
end
private private
def sso_token_key(token) def sso_token_key(token)
+6
View File
@@ -128,6 +128,12 @@ class Conversation < ApplicationRecord
additional_attributes&.dig('conversation_language') additional_attributes&.dig('conversation_language')
end end
# Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
# Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
# if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
# if you compare or copy these values in Ruby, also in our specs
# So in specs rely on to be_with(1.second) instead of to eq()
# TODO: Migrate to use a timestamp with microsecond precision
def last_activity_at def last_activity_at
self[:last_activity_at] || created_at self[:last_activity_at] || created_at
end end
+2 -2
View File
@@ -16,8 +16,8 @@
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE # index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
# #
class EmailTemplate < ApplicationRecord class EmailTemplate < ApplicationRecord
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum template_type: { layout: 0, content: 1 } enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account } validates :name, uniqueness: { scope: :account }
+5 -1
View File
@@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017 # https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate. # FIX ME : fixes breakage of installation config. we need to migrate.
# Fix configuration in application.rb # Fix configuration in application.rb
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
before_validation :set_lock before_validation :set_lock
validates :name, presence: true validates :name, presence: true
@@ -32,6 +32,10 @@ class InstallationConfig < ApplicationRecord
after_commit :clear_cache after_commit :clear_cache
def value def value
# This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin
# It was throwing error as the default value of column '{}' was failing in deserialization.
return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}'
serialized_value[:value] serialized_value[:value]
end end
+7 -1
View File
@@ -185,7 +185,13 @@ class Message < ApplicationRecord
# move this to a presenter # move this to a presenter
return self[:content] if !input_csat? || inbox.web_widget? return self[:content] if !input_csat? || inbox.web_widget?
I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}") survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}"
if inbox.csat_config&.dig('message').present?
"#{inbox.csat_config['message']} #{survey_link}"
else
I18n.t('conversations.survey.response', link: survey_link)
end
end end
def email_notifiable_message? def email_notifiable_message?
+2 -2
View File
@@ -109,8 +109,8 @@ class User < ApplicationRecord
self.email = email.try(:downcase) self.email = email.try(:downcase)
end end
def send_devise_notification(notification, *args) def send_devise_notification(notification, *)
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
end end
def set_password_and_uid def set_password_and_uid
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class Account::SignUpEmailValidationService
include CustomExceptions::Account
attr_reader :email
def initialize(email)
@email = email
end
def perform
address = ValidEmail2::Address.new(email)
raise InvalidEmail.new({ valid: false, disposable: nil }) unless address.valid?
raise InvalidEmail.new({ domain_blocked: true }) if domain_blocked?
raise InvalidEmail.new({ valid: true, disposable: true }) if address.disposable?
true
end
private
def domain_blocked?
domain = email.split('@').last&.downcase
blocked_domains.any? { |blocked_domain| domain.match?(blocked_domain.downcase) }
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end
@@ -28,16 +28,6 @@ class Conversations::FilterService < FilterService
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
) )
account_user = @account.account_users.find_by(user_id: @user.id)
is_administrator = account_user&.role == 'administrator'
# Ensure we only include conversations from inboxes the user has access to
unless is_administrator
inbox_ids = @user.inboxes.where(account_id: @account.id).pluck(:id)
conversations = conversations.where(inbox_id: inbox_ids)
end
# Apply permission-based filtering
Conversations::PermissionFilterService.new( Conversations::PermissionFilterService.new(
conversations, conversations,
@user, @user,
@@ -8,9 +8,23 @@ class Conversations::PermissionFilterService
end end
def perform def perform
# The base implementation simply returns all conversations return conversations if user_role == 'administrator'
# Enterprise edition extends this with permission-based filtering
conversations accessible_conversations
end
private
def accessible_conversations
conversations.where(inbox: user.inboxes.where(account_id: account.id))
end
def account_user
AccountUser.find_by(account_id: account.id, user_id: user.id)
end
def user_role
account_user&.role
end end
end end
@@ -12,7 +12,6 @@ class MessageTemplates::Template::CsatSurvey
private private
delegate :contact, :account, :inbox, to: :conversation delegate :contact, :account, :inbox, to: :conversation
delegate :csat_config, to: :inbox
def should_send_csat_survey? def should_send_csat_survey?
return true unless survey_rules_configured? return true unless survey_rules_configured?
@@ -137,14 +137,19 @@ class Twilio::IncomingMessageService
end end
def download_with_auth(media_url) def download_with_auth(media_url)
Down.download( auth_credentials = if twilio_channel.api_key_sid.present?
media_url, # When using api_key_sid, the auth token should be the api_secret_key
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid] [twilio_channel.api_key_sid, twilio_channel.auth_token]
) else
# When using account_sid, the auth token is the account's auth token
[twilio_channel.account_sid, twilio_channel.auth_token]
end
Down.download(media_url, http_basic_authentication: auth_credentials)
end end
def handle_download_attachment_error(error, media_url) def handle_download_attachment_error(error, media_url)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying" Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
Down.download(media_url) Down.download(media_url)
rescue StandardError => e rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping" Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
+1 -1
View File
@@ -2,7 +2,7 @@
<hr/> <hr/>
<div class="form-actions"> <div class="form-actions">
<p class="text-color-red">Caution: Any actions executed after impersonate will appear as if performed by the impersonated user - [<%= page.resource.name %> ]</p> <p class="text-color-red">Caution: Any actions executed after impersonate will appear as if performed by the impersonated user - [<%= page.resource.name %> ]</p>
<a class='button' target='_blank' href='<%= page.resource.generate_sso_link %>'>Impersonate user </a> <a class='button' target='_blank' href='<%= page.resource.generate_sso_link_with_impersonation %>'>Impersonate user </a>
</div> </div>
</section> </section>
+3 -3
View File
@@ -16,10 +16,10 @@ KillMode=mixed
StandardInput=null StandardInput=null
SyslogIdentifier=%p SyslogIdentifier=%p
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin" Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000" Environment="PORT=3000"
Environment="RAILS_ENV=production" Environment="RAILS_ENV=production"
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true" Environment="RAILS_LOG_TO_STDOUT=true"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3" Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global" Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
+3 -3
View File
@@ -21,10 +21,10 @@ MemoryHigh=1.4G
MemorySwapMax=0 MemorySwapMax=0
OOMPolicy=stop OOMPolicy=stop
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin" Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000" Environment="PORT=3000"
Environment="RAILS_ENV=production" Environment="RAILS_ENV=production"
Environment="NODE_ENV=production" Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true" Environment="RAILS_LOG_TO_STDOUT=true"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3" Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global" Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
+2 -2
View File
@@ -338,8 +338,8 @@ function setup_chatwoot() {
sudo -i -u chatwoot << EOF sudo -i -u chatwoot << EOF
rvm --version rvm --version
rvm autolibs disable rvm autolibs disable
rvm install "ruby-3.3.3" rvm install "ruby-3.4.4"
rvm use 3.3.3 --default rvm use 3.4.4 --default
git clone https://github.com/chatwoot/chatwoot.git git clone https://github.com/chatwoot/chatwoot.git
cd chatwoot cd chatwoot
+4 -4
View File
@@ -1,6 +1,6 @@
# pre-build stage # pre-build stage
FROM node:23-alpine as node FROM node:23-alpine as node
FROM ruby:3.3.3-alpine3.19 AS pre-builder FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="23.7.0" ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0" ARG PNPM_VERSION="10.2.0"
@@ -90,13 +90,13 @@ RUN if [ "$RAILS_ENV" = "production" ]; then \
RUN git rev-parse HEAD > /app/.git_sha RUN git rev-parse HEAD > /app/.git_sha
# Remove unnecessary files # Remove unnecessary files
RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
&& find /gems/ruby/3.3.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \ && find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
&& rm -rf .git \ && rm -rf .git \
&& rm .gitignore && rm .gitignore
# final build stage # final build stage
FROM ruby:3.3.3-alpine3.19 FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="23.7.0" ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0" ARG PNPM_VERSION="10.2.0"
@@ -6,12 +6,12 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
@custom_roles = Current.account.custom_roles @custom_roles = Current.account.custom_roles
end end
def show; end
def create def create
@custom_role = Current.account.custom_roles.create!(permitted_params) @custom_role = Current.account.custom_roles.create!(permitted_params)
end end
def show; end
def update def update
@custom_role.update!(permitted_params) @custom_role.update!(permitted_params)
end end
@@ -6,12 +6,12 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
@sla_policies = Current.account.sla_policies @sla_policies = Current.account.sla_policies
end end
def show; end
def create def create
@sla_policy = Current.account.sla_policies.create!(permitted_params) @sla_policy = Current.account.sla_policies.create!(permitted_params)
end end
def show; end
def update def update
@sla_policy.update!(permitted_params) @sla_policy.update!(permitted_params)
end end
@@ -50,7 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def message_content(message) def message_content(message)
return message.content if message.content.present? return message.content if message.content.present?
'User has shared an attachment' if message.attachments.any? return 'User has shared an attachment' if message.attachments.any?
'User has shared a message without content' 'User has shared a message without content'
end end
@@ -15,9 +15,9 @@ class Captain::ToolRegistryService
@registered_tools << tool.to_registry_format @registered_tools << tool.to_registry_format
end end
def method_missing(method_name, *arguments) def method_missing(method_name, *)
if @tools.key?(method_name.to_s) if @tools.key?(method_name.to_s)
@tools[method_name.to_s].execute(*arguments) @tools[method_name.to_s].execute(*)
else else
super super
end end
@@ -1,8 +1,9 @@
class Captain::Tools::BaseService class Captain::Tools::BaseService
attr_accessor :assistant attr_accessor :assistant
def initialize(assistant) def initialize(assistant, user: nil)
@assistant = assistant @assistant = assistant
@user = user
end end
def name def name
@@ -0,0 +1,72 @@
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService
def name
'search_conversations'
end
def description
'Search conversations based on parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: []
}
end
def execute(arguments)
status = arguments['status']
contact_id = arguments['contact_id']
priority = arguments['priority']
conversations = get_conversations(status, contact_id, priority)
return 'No conversations found' unless conversations.exists?
total_count = conversations.count
conversations = conversations.limit(100)
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
RESPONSE
end
private
def get_conversations(status, contact_id, priority)
conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
conversations = conversations.where(status: status) if status.present?
conversations = conversations.where(priority: priority) if priority.present?
conversations
end
def permissible_conversations
Conversations::PermissionFilterService.new(
@assistant.account.conversations,
@user,
@assistant.account
).perform
end
def properties
{
contact_id: {
type: 'number',
description: 'Filter conversations by contact ID'
},
status: {
type: 'string',
enum: %w[open resolved pending snoozed],
description: 'Filter conversations by status'
},
priority: {
type: 'string',
enum: %w[low medium high urgent],
description: 'Filter conversations by priority'
}
}
end
end
@@ -1,36 +1,37 @@
module Enterprise::Conversations::PermissionFilterService module Enterprise::Conversations::PermissionFilterService
def perform def perform
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id) return filter_by_permissions(permissions) if user_has_custom_role?
permissions = account_user&.permissions || []
user_role = account_user&.role
# Skip filtering for administrators super
return conversations if user_role == 'administrator'
# Skip filtering for regular agents (without custom roles/permissions)
return conversations if user_role == 'agent' && account_user&.custom_role_id.nil?
filter_by_permissions(permissions)
end end
private private
def user_has_custom_role?
user_role == 'agent' && account_user&.custom_role_id.present?
end
def permissions
account_user&.permissions || []
end
def filter_by_permissions(permissions) def filter_by_permissions(permissions)
# Permission-based filtering with hierarchy # Permission-based filtering with hierarchy
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage # conversation_manage > conversation_unassigned_manage > conversation_participating_manage
if permissions.include?('conversation_manage') if permissions.include?('conversation_manage')
conversations accessible_conversations
elsif permissions.include?('conversation_unassigned_manage') elsif permissions.include?('conversation_unassigned_manage')
filter_unassigned_and_mine filter_unassigned_and_mine
elsif permissions.include?('conversation_participating_manage') elsif permissions.include?('conversation_participating_manage')
conversations.assigned_to(user) accessible_conversations.assigned_to(user)
else else
Conversation.none Conversation.none
end end
end end
def filter_unassigned_and_mine def filter_unassigned_and_mine
mine = conversations.assigned_to(user) mine = accessible_conversations.assigned_to(user)
unassigned = conversations.unassigned unassigned = accessible_conversations.unassigned
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations") Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
.where(account_id: account.id) .where(account_id: account.id)
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountBuilder do
let(:email) { 'user@example.com' }
let(:user_password) { 'Password123!' }
let(:account_name) { 'Test Account' }
let(:user_full_name) { 'Test User' }
let(:validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
let(:account_builder) do
described_class.new(
account_name: account_name,
email: email,
user_full_name: user_full_name,
user_password: user_password,
confirmed: true
)
end
# Mock the email validation service
before do
allow(Account::SignUpEmailValidationService).to receive(:new).with(email).and_return(validation_service)
end
describe '#perform' do
context 'when valid params are passed' do
it 'creates a new account with correct name' do
_user, account = account_builder.perform
expect(account).to be_an(Account)
expect(account.name).to eq(account_name)
end
it 'creates a new confirmed user with correct details' do
user, _account = account_builder.perform
expect(user).to be_a(User)
expect(user.email).to eq(email)
expect(user.name).to eq(user_full_name)
expect(user.confirmed?).to be(true)
end
it 'links user to account as administrator' do
user, account = account_builder.perform
expect(user.account_users.first.role).to eq('administrator')
expect(user.accounts.first).to eq(account)
end
it 'increments the counts of models' do
expect do
account_builder.perform
end.to change(Account, :count).by(1)
.and change(User, :count).by(1)
.and change(AccountUser, :count).by(1)
end
end
end
end
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end end
describe '#timeseries' do describe '#timeseries' do
include_examples 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
end end
describe '#aggregate_value' do describe '#aggregate_value' do
include_examples 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
end end
end end
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
let(:account_builder) { double } let(:account_builder) { double }
let(:user_double) { object_double(:user) } let(:user_double) { object_double(:user) }
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
def set_omniauth_config(for_email = 'test@example.com') def set_omniauth_config(for_email = 'test@example.com')
OmniAuth.config.test_mode = true OmniAuth.config.test_mode = true
@@ -17,13 +18,18 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
) )
end end
before do
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
end
describe '#omniauth_sucess' do describe '#omniauth_sucess' do
it 'allows signup' do it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com') set_omniauth_config('test_not_preset@example.com')
allow(AccountBuilder).to receive(:new).and_return(account_builder) allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform).and_return(user_double) allow(account_builder).to receive(:perform).and_return(user_double)
allow(Avatar::AvatarFromUrlJob).to receive(:perform_later).and_return(true) allow(Avatar::AvatarFromUrlJob).to receive(:perform_later).and_return(true)
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
@@ -43,8 +49,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end end
it 'blocks personal accounts signup' do it 'blocks personal accounts signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('personal@gmail.com') set_omniauth_config('personal@gmail.com')
allow(email_validation_service).to receive(:perform).and_raise(CustomExceptions::Account::InvalidEmail.new({ valid: false, disposable: nil }))
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback # expect a 302 redirect to auth/google_oauth2/callback
@@ -57,10 +65,13 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end end
it 'blocks personal accounts signup with different Gmail case variations' do it 'blocks personal accounts signup with different Gmail case variations' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
# Test different case variations of Gmail # Test different case variations of Gmail
['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email| ['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email|
set_omniauth_config(email) set_omniauth_config(email)
allow(email_validation_service).to receive(:perform).and_raise(CustomExceptions::Account::InvalidEmail.new({ valid: false,
disposable: nil }))
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback # expect a 302 redirect to auth/google_oauth2/callback
@@ -76,8 +87,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
# This test does not affect line coverage, but it is important to ensure that the logic # This test does not affect line coverage, but it is important to ensure that the logic
# does not allow any signup if the ENV explicitly disables it # does not allow any signup if the ENV explicitly disables it
it 'blocks signup if ENV disabled' do it 'blocks signup if ENV disabled' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false' do with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('does-not-exist-for-sure@example.com') set_omniauth_config('does-not-exist-for-sure@example.com')
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback # expect a 302 redirect to auth/google_oauth2/callback
@@ -90,38 +103,42 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end end
it 'allows login' do it 'allows login' do
create(:user, email: 'test@example.com') with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test@example.com') create(:user, email: 'test@example.com')
set_omniauth_config('test@example.com')
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback # expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect! follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# expect app/login page to respond with 200 and render # expect app/login page to respond with 200 and render
follow_redirect! follow_redirect!
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end
end end
# from a line coverage point of view this may seem redundant # from a line coverage point of view this may seem redundant
# but to ensure that the logic allows for existing users even if they have a gmail account # but to ensure that the logic allows for existing users even if they have a gmail account
# we need to test this explicitly # we need to test this explicitly
it 'allows personal account login' do it 'allows personal account login' do
create(:user, email: 'personal-existing@gmail.com') with_modified_env FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('personal-existing@gmail.com') create(:user, email: 'personal-existing@gmail.com')
set_omniauth_config('personal-existing@gmail.com')
get '/omniauth/google_oauth2/callback' get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback # expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect! follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# expect app/login page to respond with 200 and render # expect app/login page to respond with 200 and render
follow_redirect! follow_redirect!
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end
end end
end end
end end
@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let(:account) { create(:account) }
let(:user) { create(:user, role: 'administrator', account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversations')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search conversations based on parameters')
end
end
describe '#parameters' do
it 'returns the correct parameter schema' do
params = service.parameters
expect(params[:type]).to eq('object')
expect(params[:properties]).to include(:contact_id, :status, :priority)
end
end
describe '#execute' do
let(:contact) { create(:contact, account: account) }
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute({})
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute({ 'status' => 'open' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute({ 'contact_id' => contact.id })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute({ 'priority' => 'high' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute({ 'status' => 'snoozed' })
expect(result).to eq('No conversations found')
end
end
end
@@ -9,6 +9,8 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) } let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
let!(:another_inbox_conversation) { create(:conversation, account: account, inbox: inbox2) }
# This inbox_member is used to establish the agent's access to the inbox # This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) } before { create(:inbox_member, user: agent, inbox: inbox) }
@@ -25,16 +27,14 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation) expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation) expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation) expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(3) expect(result.count).to eq(4)
end end
end end
context 'when user is a regular agent' do context 'when user is a regular agent' do
it 'returns all conversations in assigned inboxes' do it 'returns all conversations in assigned inboxes' do
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
result = Conversations::PermissionFilterService.new( result = Conversations::PermissionFilterService.new(
account.conversations.where(inbox_id: inbox_ids), account.conversations,
agent, agent,
account account
).perform ).perform
@@ -42,6 +42,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation) expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation) expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation) expect(result).to include(another_assigned_conversation)
expect(result).not_to include(another_inbox_conversation)
expect(result.count).to eq(3) expect(result.count).to eq(3)
end end
end end
@@ -52,7 +53,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment # Create a new isolated test environment
test_account = create(:account) test_account = create(:account)
test_inbox = create(:inbox, account: test_account) test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent # Create test agent
test_agent = create(:user, account: test_account, role: :agent) test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox) create(:inbox_member, user: test_agent, inbox: test_inbox)
@@ -66,6 +67,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test # Run the test
result = Conversations::PermissionFilterService.new( result = Conversations::PermissionFilterService.new(
@@ -79,6 +81,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation) expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation) expect(result).to include(unassigned_conversation)
expect(result).to include(other_assigned_conversation) expect(result).to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end end
end end
@@ -87,6 +90,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment # Create a new isolated test environment
test_account = create(:account) test_account = create(:account)
test_inbox = create(:inbox, account: test_account) test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent # Create test agent
test_agent = create(:user, account: test_account, role: :agent) test_agent = create(:user, account: test_account, role: :agent)
@@ -101,6 +105,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create some conversations # Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox) other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test # Run the test
result = Conversations::PermissionFilterService.new( result = Conversations::PermissionFilterService.new(
@@ -114,6 +119,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result.first.assignee).to eq(test_agent) expect(result.first.assignee).to eq(test_agent)
expect(result).to include(assigned_conversation) expect(result).to include(assigned_conversation)
expect(result).not_to include(other_conversation) expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end end
end end
@@ -122,6 +128,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment # Create a new isolated test environment
test_account = create(:account) test_account = create(:account)
test_inbox = create(:inbox, account: test_account) test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent # Create test agent
test_agent = create(:user, account: test_account, role: :agent) test_agent = create(:user, account: test_account, role: :agent)
@@ -137,6 +144,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test # Run the test
result = Conversations::PermissionFilterService.new( result = Conversations::PermissionFilterService.new(
@@ -152,6 +160,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Should NOT include conversations assigned to others # Should NOT include conversations assigned to others
expect(result).not_to include(other_assigned_conversation) expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end end
end end
@@ -160,6 +169,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment # Create a new isolated test environment
test_account = create(:account) test_account = create(:account)
test_inbox = create(:inbox, account: test_account) test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent # Create test agent
test_agent = create(:user, account: test_account, role: :agent) test_agent = create(:user, account: test_account, role: :agent)
@@ -176,6 +186,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent) assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil) unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account)) other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test # Run the test
result = Conversations::PermissionFilterService.new( result = Conversations::PermissionFilterService.new(
@@ -191,6 +202,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(unassigned_conversation) expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_to_agent) expect(result).to include(assigned_to_agent)
expect(result).not_to include(other_assigned_conversation) expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end end
end end
end end
+2 -2
View File
@@ -793,8 +793,8 @@ RSpec.describe Conversation do
end end
context 'when a new conversation is created' do context 'when a new conversation is created' do
it 'sets last_activity_at to the created_at time' do it 'sets last_activity_at to the created_at time (within DB precision)' do
expect(conversation.last_activity_at).to eq(conversation.created_at) expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
end end
end end
+31
View File
@@ -475,4 +475,35 @@ RSpec.describe Message do
end end
end end
end end
describe '#content' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') }
it 'returns original content for web widget inbox' do
allow(message.inbox).to receive(:web_widget?).and_return(true)
expect(message.content).to eq('Original content')
end
context 'when inbox is not a web widget' do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns custom message with survey link when csat message is configured' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' })
expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
it 'returns default message with survey link when no custom csat message' do
allow(message.inbox).to receive(:csat_config).and_return(nil)
allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
.and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
end
end
end end
@@ -0,0 +1,74 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account::SignUpEmailValidationService, type: :service do
let(:service) { described_class.new(email) }
let(:blocked_domains) { "gmail.com\noutlook.com" }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false) }
let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: true) }
let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
before do
allow(GlobalConfigService).to receive(:load).with('BLOCKED_EMAIL_DOMAINS', '').and_return(blocked_domains)
end
describe '#perform' do
context 'when email is invalid format' do
let(:email) { 'invalid-email' }
it 'raises InvalidEmail with invalid message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
end
end
end
context 'when domain is blocked' do
let(:email) { 'test@gmail.com' }
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
end
context 'when domain is blocked (case insensitive)' do
let(:email) { 'test@GMAIL.COM' }
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
end
context 'when email is from disposable provider' do
let(:email) { 'test@mailinator.com' }
it 'raises InvalidEmail with disposable message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end
end
context 'when email is valid business email' do
let(:email) { 'test@example.com' }
it 'returns true' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect(service.perform).to be(true)
end
end
end
end
@@ -183,7 +183,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100 expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
# Verify that not all messages are included (some were truncated) # Verify that not all messages are included (some were truncated)
expect(messages.count).to be > result.scan(/John Doe:/).count expect(messages.count).to be > result.scan('John Doe:').count
end end
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do
+73 -219
View File
@@ -46,9 +46,7 @@
}, },
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"paths": { "paths": {
@@ -62,9 +60,7 @@
"description": "Create an Account", "description": "Create an Account",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -105,9 +101,7 @@
"description": "Get the details of an account", "description": "Get the details of an account",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -134,9 +128,7 @@
"description": "Update an account's attributes", "description": "Update an account's attributes",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -170,9 +162,7 @@
"description": "Delete an Account", "description": "Delete an Account",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -203,9 +193,7 @@
"description": "List all account users", "description": "List all account users",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -247,9 +235,7 @@
"description": "Create an Account User", "description": "Create an Account User",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -310,9 +296,7 @@
"description": "Delete an Account User", "description": "Delete an Account User",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -357,9 +341,7 @@
"description": "List all agent bots available", "description": "List all agent bots available",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -387,9 +369,7 @@
"description": "Create an agent bot", "description": "Create an agent bot",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -430,9 +410,7 @@
"description": "Get the details of an agent bot", "description": "Get the details of an agent bot",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -459,9 +437,7 @@
"description": "Update an agent bot's attributes", "description": "Update an agent bot's attributes",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -495,9 +471,7 @@
"description": "Delete an AgentBot", "description": "Delete an AgentBot",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -523,9 +497,7 @@
"description": "Create a User", "description": "Create a User",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -566,9 +538,7 @@
"description": "Get the details of an user", "description": "Get the details of an user",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -595,9 +565,7 @@
"description": "Update a user's attributes", "description": "Update a user's attributes",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -631,9 +599,7 @@
"description": "Delete a User", "description": "Delete a User",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -664,9 +630,7 @@
"description": "Get the sso link of a user", "description": "Get the sso link of a user",
"security": [ "security": [
{ {
"platformAppApiKey": [ "platformAppApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -704,9 +668,7 @@
"operationId": "get-details-of-a-inbox", "operationId": "get-details-of-a-inbox",
"summary": "Inbox details", "summary": "Inbox details",
"description": "Get the details of an inbox", "description": "Get the details of an inbox",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@@ -736,9 +698,7 @@
"operationId": "create-a-contact", "operationId": "create-a-contact",
"summary": "Create a contact", "summary": "Create a contact",
"description": "Create a contact", "description": "Create a contact",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "data", "name": "data",
@@ -778,9 +738,7 @@
"operationId": "get-details-of-a-contact", "operationId": "get-details-of-a-contact",
"summary": "Get a contact", "summary": "Get a contact",
"description": "Get the details of a contact", "description": "Get the details of a contact",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@@ -803,9 +761,7 @@
"operationId": "update-a-contact", "operationId": "update-a-contact",
"summary": "Update a contact", "summary": "Update a contact",
"description": "Update a contact's attributes", "description": "Update a contact's attributes",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "data", "name": "data",
@@ -845,9 +801,7 @@
"operationId": "create-a-conversation", "operationId": "create-a-conversation",
"summary": "Create a conversation", "summary": "Create a conversation",
"description": "Create a conversation", "description": "Create a conversation",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "data", "name": "data",
@@ -1056,9 +1010,7 @@
"operationId": "create-a-message", "operationId": "create-a-message",
"summary": "Create a message", "summary": "Create a message",
"description": "Create a message", "description": "Create a message",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "data", "name": "data",
@@ -1127,9 +1079,7 @@
"operationId": "update-a-message", "operationId": "update-a-message",
"summary": "Update a message", "summary": "Update a message",
"description": "Update a message", "description": "Update a message",
"security": [ "security": [],
],
"parameters": [ "parameters": [
{ {
"name": "data", "name": "data",
@@ -1239,9 +1189,7 @@
"operationId": "get-csat-survey-page", "operationId": "get-csat-survey-page",
"summary": "Get CSAT survey page", "summary": "Get CSAT survey page",
"description": "You can redirect the client to this URL, instead of implementing the CSAT survey component yourself.", "description": "You can redirect the client to this URL, instead of implementing the CSAT survey component yourself.",
"security": [ "security": [],
],
"responses": { "responses": {
"200": { "200": {
"description": "Success" "description": "Success"
@@ -1406,9 +1354,7 @@
"description": "Get Details of Agents in an Account", "description": "Get Details of Agents in an Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -1436,9 +1382,7 @@
"description": "Add a new Agent to Account", "description": "Add a new Agent to Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1515,9 +1459,7 @@
"description": "Update an Agent in Account", "description": "Update an Agent in Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1587,9 +1529,7 @@
"description": "Remove an Agent from Account", "description": "Remove an Agent from Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1629,9 +1569,7 @@
"description": "Get Details of Canned Responses in an Account", "description": "Get Details of Canned Responses in an Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -1659,9 +1597,7 @@
"description": "Add a new Canned Response to Account", "description": "Add a new Canned Response to Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1702,9 +1638,7 @@
"description": "Update a Canned Response in Account", "description": "Update a Canned Response in Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1748,9 +1682,7 @@
"description": "Remove a Canned Response from Account", "description": "Remove a Canned Response from Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1803,9 +1735,7 @@
"description": "Get details of custom attributes in an Account", "description": "Get details of custom attributes in an Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -1833,9 +1763,7 @@
"description": "Add a new custom attribute to account", "description": "Add a new custom attribute to account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1917,9 +1845,7 @@
"description": "Update a custom attribute in account", "description": "Update a custom attribute in account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -1963,9 +1889,7 @@
"description": "Remove a custom attribute from account", "description": "Remove a custom attribute from account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2246,14 +2170,10 @@
"summary": "Contact Filter", "summary": "Contact Filter",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2466,9 +2386,7 @@
"description": "Get details of automation rules in an Account", "description": "Get details of automation rules in an Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -2496,9 +2414,7 @@
"description": "Add a new automation rule to account", "description": "Add a new automation rule to account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2577,9 +2493,7 @@
"description": "Update a automation rule in account", "description": "Update a automation rule in account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2623,9 +2537,7 @@
"description": "Remove a automation rule from account", "description": "Remove a automation rule from account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2665,9 +2577,7 @@
"description": "Add a new portal to account", "description": "Add a new portal to account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2706,9 +2616,7 @@
"description": "Get details of portals in an Account", "description": "Get details of portals in an Account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"responses": { "responses": {
@@ -2736,9 +2644,7 @@
"description": "update a new portal to account", "description": "update a new portal to account",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2782,9 +2688,7 @@
"description": "Add a new category to portal", "description": "Add a new category to portal",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -2828,9 +2732,7 @@
"description": "Add a new article to portal", "description": "Add a new article to portal",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3041,14 +2943,10 @@
"description": "Creating a conversation in chatwoot requires a source id. \n\n Learn more about source_id: https://github.com/chatwoot/chatwoot/wiki/Building-on-Top-of-Chatwoot:-Importing-Existing-Contacts-and-Creating-Conversations", "description": "Creating a conversation in chatwoot requires a source id. \n\n Learn more about source_id: https://github.com/chatwoot/chatwoot/wiki/Building-on-Top-of-Chatwoot:-Importing-Existing-Contacts-and-Creating-Conversations",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3191,14 +3089,10 @@
"summary": "Conversations Filter", "summary": "Conversations Filter",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3329,14 +3223,10 @@
"description": "Update Conversation Attributes", "description": "Update Conversation Attributes",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3397,14 +3287,10 @@
"description": "Toggles the status of the conversation between open and resolved", "description": "Toggles the status of the conversation between open and resolved",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3465,14 +3351,10 @@
"description": "Toggles the priority of conversation", "description": "Toggles the priority of conversation",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3532,14 +3414,10 @@
"description": "Updates the custom attributes of a conversation", "description": "Updates the custom attributes of a conversation",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -3605,14 +3483,10 @@
"description": "Assign a conversation to an agent or a team", "description": "Assign a conversation to an agent or a team",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4063,9 +3937,7 @@
"description": "Get Details of Agents in an Inbox", "description": "Get Details of Agents in an Inbox",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4108,9 +3980,7 @@
"description": "Add a new Agent to Inbox", "description": "Add a new Agent to Inbox",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4171,9 +4041,7 @@
"description": "All agents except the one passed in params will be removed", "description": "All agents except the one passed in params will be removed",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4234,9 +4102,7 @@
"description": "Remove an Agent from Inbox", "description": "Remove an Agent from Inbox",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4333,14 +4199,10 @@
"description": "Create a new message in the conversation", "description": "Create a new message in the conversation",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
}, },
{ {
"agentBotApiKey": [ "agentBotApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4720,9 +4582,7 @@
"description": "Get Details of Agents in an Team", "description": "Get Details of Agents in an Team",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4761,9 +4621,7 @@
"description": "Add a new Agent to Team", "description": "Add a new Agent to Team",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4819,9 +4677,7 @@
"description": "All agents except the one passed in params will be removed", "description": "All agents except the one passed in params will be removed",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [
@@ -4877,9 +4733,7 @@
"description": "Remove an Agent from Team", "description": "Remove an Agent from Team",
"security": [ "security": [
{ {
"userApiKey": [ "userApiKey": []
]
} }
], ],
"parameters": [ "parameters": [