Merge branch 'develop' into chore/fix-dialogflow-language-selection-and-retries-ic7wc9
This commit is contained in:
@@ -73,15 +73,15 @@ jobs:
|
||||
libvips
|
||||
|
||||
- run:
|
||||
name: Install RVM and Ruby 3.3.3
|
||||
name: Install RVM and Ruby 3.4.4
|
||||
command: |
|
||||
sudo apt-get install -y gpg
|
||||
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
|
||||
\curl -sSL https://get.rvm.io | bash -s stable
|
||||
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
|
||||
source ~/.rvm/scripts/rvm
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
rvm install "3.4.4"
|
||||
rvm use 3.4.4 --default
|
||||
gem install bundler -v 2.5.16
|
||||
|
||||
- run:
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
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.
|
||||
USER_UID: '1000'
|
||||
USER_GID: '1000'
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
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.
|
||||
USER_UID: '1000'
|
||||
USER_GID: '1000'
|
||||
|
||||
+179
-8
@@ -1,7 +1,10 @@
|
||||
require:
|
||||
plugins:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
- rubocop-factory_bot
|
||||
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
|
||||
@@ -13,44 +16,61 @@ Metrics/ClassLength:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 19
|
||||
Exclude:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
|
||||
Style/ExponentialNotation:
|
||||
Enabled: false
|
||||
|
||||
Style/FrozenStringLiteralComment:
|
||||
Enabled: false
|
||||
|
||||
Style/SymbolArray:
|
||||
Enabled: false
|
||||
|
||||
Style/OpenStructUse:
|
||||
Enabled: false
|
||||
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
- 'app/dispatchers/dispatcher.rb'
|
||||
|
||||
Style/GlobalVars:
|
||||
Exclude:
|
||||
- 'config/initializers/01_redis.rb'
|
||||
- 'config/initializers/rack_attack.rb'
|
||||
- 'lib/redis/alfred.rb'
|
||||
- 'lib/global_config.rb'
|
||||
|
||||
Style/ClassVars:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
|
||||
Lint/MissingSuper:
|
||||
Exclude:
|
||||
- 'app/drops/base_drop.rb'
|
||||
|
||||
Lint/SymbolConversion:
|
||||
Enabled: false
|
||||
|
||||
Lint/EmptyBlock:
|
||||
Exclude:
|
||||
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
|
||||
|
||||
Lint/OrAssignmentToConstant:
|
||||
Exclude:
|
||||
- 'lib/redis/config.rb'
|
||||
|
||||
Metrics/BlockLength:
|
||||
Max: 30
|
||||
Exclude:
|
||||
@@ -58,10 +78,16 @@ Metrics/BlockLength:
|
||||
- '**/routes.rb'
|
||||
- 'config/environments/*'
|
||||
- db/schema.rb
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/seeders/message_seeder.rb
|
||||
- spec/support/slack_stubs.rb
|
||||
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
- enterprise/app/helpers/captain/chat_helper.rb
|
||||
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
@@ -71,74 +97,101 @@ Rails/ApplicationController:
|
||||
- 'app/controllers/platform_controller.rb'
|
||||
- 'app/controllers/public_controller.rb'
|
||||
- 'app/controllers/survey/responses_controller.rb'
|
||||
|
||||
Rails/FindEach:
|
||||
Enabled: true
|
||||
Include:
|
||||
- 'app/**/*.rb'
|
||||
|
||||
Rails/CompactBlank:
|
||||
Enabled: false
|
||||
|
||||
Rails/EnvironmentVariableAccess:
|
||||
Enabled: false
|
||||
|
||||
Rails/TimeZoneAssignment:
|
||||
Enabled: false
|
||||
|
||||
Rails/RedundantPresenceValidationOnBelongsTo:
|
||||
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:
|
||||
EnforcedStyle: compact
|
||||
Exclude:
|
||||
- 'config/application.rb'
|
||||
- 'config/initializers/monkey_patches/*'
|
||||
|
||||
Style/MapToHash:
|
||||
Enabled: false
|
||||
|
||||
Style/HashSyntax:
|
||||
Enabled: true
|
||||
EnforcedStyle: no_mixed_keys
|
||||
EnforcedShorthandSyntax: never
|
||||
|
||||
RSpec/NestedGroups:
|
||||
Enabled: true
|
||||
Max: 4
|
||||
|
||||
RSpec/MessageSpies:
|
||||
Enabled: false
|
||||
|
||||
RSpec/StubbedMock:
|
||||
Enabled: false
|
||||
RSpec/FactoryBot/SyntaxMethods:
|
||||
Enabled: false
|
||||
|
||||
Naming/VariableNumber:
|
||||
Enabled: false
|
||||
|
||||
Naming/MemoizedInstanceVariableName:
|
||||
Exclude:
|
||||
- 'app/models/message.rb'
|
||||
|
||||
Style/GuardClause:
|
||||
Exclude:
|
||||
- 'app/builders/account_builder.rb'
|
||||
- 'app/models/attachment.rb'
|
||||
- 'app/models/message.rb'
|
||||
|
||||
Metrics/AbcSize:
|
||||
Max: 26
|
||||
Exclude:
|
||||
- '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/canned_response.rb'
|
||||
- 'app/models/telegram_bot.rb'
|
||||
|
||||
Rails/RenderInline:
|
||||
Exclude:
|
||||
- 'app/controllers/swagger_controller.rb'
|
||||
|
||||
Rails/ThreeStateBooleanColumn:
|
||||
Exclude:
|
||||
- 'db/migrate/20230503101201_create_sla_policies.rb'
|
||||
|
||||
RSpec/IndexedLet:
|
||||
Enabled: false
|
||||
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
|
||||
# we should bring this down
|
||||
RSpec/MultipleExpectations:
|
||||
Max: 7
|
||||
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 14
|
||||
|
||||
@@ -166,3 +219,121 @@ AllCops:
|
||||
- 'tmp/**/*'
|
||||
- 'storage/**/*'
|
||||
- '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
@@ -1 +1 @@
|
||||
3.3.3
|
||||
3.4.4
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.2.3'
|
||||
ruby '3.4.4'
|
||||
|
||||
##-- base gems for rails --##
|
||||
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
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -237,9 +237,10 @@ group :development, :test do
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
gem 'rubocop-rspec', require: false
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
end
|
||||
+161
-141
@@ -25,76 +25,89 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actioncable (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionmailer (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
actionview (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activejob (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activerecord (7.0.8.7)
|
||||
activemodel (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activerecord-import (1.4.1)
|
||||
activemodel (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activerecord (7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
timeout (>= 0.4.0)
|
||||
activerecord-import (2.1.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
activestorage (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.7)
|
||||
activesupport (7.1.5.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0)
|
||||
acts-as-taggable-on (9.0.1)
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
administrate (0.20.1)
|
||||
@@ -116,7 +129,7 @@ GEM
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.2)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
@@ -142,14 +155,15 @@ GEM
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
bigdecimal (3.1.8)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (5.4.1)
|
||||
browser (5.3.1)
|
||||
builder (3.3.0)
|
||||
bullet (7.0.7)
|
||||
bullet (8.0.7)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
@@ -161,8 +175,8 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
@@ -176,16 +190,10 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
datadog-ci (0.8.3)
|
||||
msgpack
|
||||
date (3.4.1)
|
||||
ddtrace (1.23.2)
|
||||
datadog-ci (~> 0.8.1)
|
||||
debase-ruby_core_source (= 3.3.1)
|
||||
libdatadog (~> 7.0.0.1.0)
|
||||
libddwaf (~> 1.14.0.0.0)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
msgpack
|
||||
debase-ruby_core_source (3.3.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -196,10 +204,10 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise_token_auth (1.2.3)
|
||||
devise_token_auth (1.2.5)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
rails (>= 4.2.0, < 8.1)
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -212,6 +220,7 @@ GEM
|
||||
railties (>= 6.1)
|
||||
down (5.4.0)
|
||||
addressable (~> 2.8)
|
||||
drb (2.2.3)
|
||||
dry-cli (1.1.0)
|
||||
ecma-re-validator (0.4.0)
|
||||
regexp_parser (~> 2.2)
|
||||
@@ -254,7 +263,10 @@ GEM
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
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 (>= 1.0.0)
|
||||
rake
|
||||
@@ -315,16 +327,13 @@ GEM
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.25.5)
|
||||
google-protobuf (3.25.5-arm64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-linux)
|
||||
google-protobuf (3.25.7)
|
||||
googleapis-common-protos (1.6.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (~> 1.7)
|
||||
grpc (~> 1.41)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleapis-common-protos-types (1.20.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
@@ -334,17 +343,17 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
groupdate (6.2.1)
|
||||
activesupport (>= 5.2)
|
||||
grpc (1.62.0)
|
||||
google-protobuf (~> 3.25)
|
||||
grpc (1.72.0)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.62.0-arm64-darwin)
|
||||
google-protobuf (~> 3.25)
|
||||
grpc (1.72.0-arm64-darwin)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.62.0-x86_64-darwin)
|
||||
google-protobuf (~> 3.25)
|
||||
grpc (1.72.0-x86_64-darwin)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.62.0-x86_64-linux)
|
||||
google-protobuf (~> 3.25)
|
||||
grpc (1.72.0-x86_64-linux)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (1.0.0)
|
||||
@@ -370,7 +379,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.6)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -388,7 +397,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.6.3)
|
||||
json (2.12.0)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -423,21 +432,13 @@ GEM
|
||||
faraday-multipart
|
||||
json (>= 1.8)
|
||||
rexml
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (2.5.2)
|
||||
addressable (~> 2.8)
|
||||
letter_opener (1.8.1)
|
||||
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)
|
||||
lint_roller (1.1.0)
|
||||
liquid (5.4.0)
|
||||
listen (3.8.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
@@ -445,7 +446,7 @@ GEM
|
||||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.0)
|
||||
logger (1.7.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -471,10 +472,10 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.4)
|
||||
minitest (5.25.5)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
@@ -545,14 +546,16 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.3)
|
||||
pg_search (2.3.6)
|
||||
activerecord (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
pgvector (0.1.1)
|
||||
prism (1.4.0)
|
||||
procore-sift (1.0.0)
|
||||
activerecord (>= 6.1)
|
||||
pry (0.14.2)
|
||||
@@ -567,7 +570,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.14)
|
||||
rack (2.2.15)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -581,23 +584,28 @@ GEM
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (1.0.2)
|
||||
rack (< 3)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.7)
|
||||
actioncable (= 7.0.8.7)
|
||||
actionmailbox (= 7.0.8.7)
|
||||
actionmailer (= 7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
actiontext (= 7.0.8.7)
|
||||
actionview (= 7.0.8.7)
|
||||
activejob (= 7.0.8.7)
|
||||
activemodel (= 7.0.8.7)
|
||||
activerecord (= 7.0.8.7)
|
||||
activestorage (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
rackup (1.0.1)
|
||||
rack (< 3)
|
||||
webrick
|
||||
rails (7.1.5.1)
|
||||
actioncable (= 7.1.5.1)
|
||||
actionmailbox (= 7.1.5.1)
|
||||
actionmailer (= 7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
actiontext (= 7.1.5.1)
|
||||
actionview (= 7.1.5.1)
|
||||
activejob (= 7.1.5.1)
|
||||
activemodel (= 7.1.5.1)
|
||||
activerecord (= 7.1.5.1)
|
||||
activestorage (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.7)
|
||||
railties (= 7.1.5.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -605,13 +613,14 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
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)
|
||||
railties (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
method_source
|
||||
railties (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rb-fsevent (0.11.2)
|
||||
@@ -623,7 +632,7 @@ GEM
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.8.0)
|
||||
regexp_parser (2.10.0)
|
||||
reline (0.3.6)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
@@ -643,7 +652,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.9)
|
||||
rexml (3.4.1)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
@@ -663,30 +672,36 @@ GEM
|
||||
rspec-support (3.13.1)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.50.2)
|
||||
rubocop (1.75.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.0.0)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.0, < 2.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.28.1)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-capybara (2.18.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-performance (1.17.1)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
rubocop-ast (>= 0.4.0)
|
||||
rubocop-rails (2.19.1)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-factory_bot (2.27.1)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-performance (1.25.0)
|
||||
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)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-rspec (2.21.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.6.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
ruby-openai (7.3.1)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
@@ -816,8 +831,10 @@ GEM
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
@@ -845,7 +862,9 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
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.5)
|
||||
wisper (2.0.0)
|
||||
@@ -951,7 +970,7 @@ DEPENDENCIES
|
||||
rack-cors (= 2.0.0)
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.0.8.4)
|
||||
rails (~> 7.1)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
@@ -960,6 +979,7 @@ DEPENDENCIES
|
||||
rspec-rails (>= 6.1.5)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-factory_bot
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
@@ -997,7 +1017,7 @@ DEPENDENCIES
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.3p157
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.16
|
||||
2.5.16
|
||||
@@ -32,14 +32,7 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def validate_email
|
||||
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
|
||||
|
||||
address = ValidEmail2::Address.new(@email)
|
||||
if address.valid? && !address.disposable?
|
||||
true
|
||||
else
|
||||
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
||||
end
|
||||
Account::SignUpEmailValidationService.new(@email).perform
|
||||
end
|
||||
|
||||
def validate_user
|
||||
@@ -81,21 +74,4 @@ class AccountBuilder
|
||||
@user.confirm if @confirmed
|
||||
@user.save!
|
||||
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
|
||||
|
||||
@@ -59,11 +59,13 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def create_contact_inbox
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
attrs = {
|
||||
contact_id: @contact.id,
|
||||
inbox_id: @inbox.id,
|
||||
source_id: @source_id
|
||||
)
|
||||
}
|
||||
|
||||
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
||||
update_old_contact_inbox
|
||||
|
||||
@@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
end
|
||||
|
||||
def message_already_exists?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||
end
|
||||
|
||||
cw_message.present?
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
@message = Message.find_by(source_id: source_id)
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
|
||||
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def article_params
|
||||
params.require(:article).permit(
|
||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
|
||||
@@ -12,10 +12,6 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
||||
Current.account
|
||||
).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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id,
|
||||
label_ids: [])
|
||||
end
|
||||
|
||||
def fetch_hook
|
||||
|
||||
@@ -21,7 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
|
||||
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: '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
|
||||
token = @resource.send(:set_reset_password_token)
|
||||
@@ -53,9 +53,11 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
).first
|
||||
end
|
||||
|
||||
def validate_business_account?
|
||||
# return true if the user is a business account, false if it is a gmail account
|
||||
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
||||
def validate_signup_email_is_business_domain?
|
||||
# return true if the user is a business account, false if it is a blocked domain account
|
||||
Account::SignUpEmailValidationService.new(auth_hash['info']['email']).perform
|
||||
rescue CustomExceptions::Account::InvalidEmail
|
||||
false
|
||||
end
|
||||
|
||||
def create_account_for_user
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Platform::Api::V1::UsersController < PlatformController
|
||||
# ref: https://stackoverflow.com/a/45190318/939299
|
||||
# set resource is called for other actions already in platform controller
|
||||
# we want to add login to that chain as well
|
||||
before_action(only: [:login]) { set_resource }
|
||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
||||
# we want to add login and token to that chain as well
|
||||
before_action(only: [:login, :token]) { set_resource }
|
||||
before_action(only: [:login, :token]) { validate_platform_app_permissible }
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
render json: { url: @resource.generate_sso_link }
|
||||
end
|
||||
|
||||
def token; end
|
||||
|
||||
def update
|
||||
@resource.assign_attributes(user_update_params)
|
||||
|
||||
|
||||
@@ -88,7 +88,10 @@ class ConversationFinder
|
||||
|
||||
def find_conversation_by_inbox
|
||||
@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
|
||||
|
||||
def find_all_conversations
|
||||
|
||||
+65
@@ -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 Avatar from 'dashboard/components-next/avatar/Avatar.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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -149,15 +150,15 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<transition
|
||||
enter-active-class="overflow-hidden transition-all duration-300 ease-out"
|
||||
leave-active-class="overflow-hidden transition-all duration-300 ease-in"
|
||||
enter-from-class="overflow-hidden opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-from-class="opacity-100 max-h-[690px] sm:max-h-[470px] md:max-h-[410px]"
|
||||
leave-to-class="overflow-hidden opacity-0 max-h-0"
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-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">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
@@ -176,8 +177,14 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
+1
-5
@@ -47,11 +47,7 @@ defineExpose({ dialogRef });
|
||||
ref="dialogRef"
|
||||
type="alert"
|
||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.TITLE')"
|
||||
:description="
|
||||
t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION', {
|
||||
contactName: props.selectedContact.name,
|
||||
})
|
||||
"
|
||||
:description="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.DESCRIPTION')"
|
||||
:confirm-button-label="t('CONTACTS_LAYOUT.DETAILS.DELETE_DIALOG.CONFIRM')"
|
||||
@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',
|
||||
ghost:
|
||||
'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: {
|
||||
solid:
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
<script setup>
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
supportAgent: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Avatar
|
||||
:name="supportAgent.available_name"
|
||||
:src="supportAgent.avatar_url"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="space-y-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
||||
<div class="break-words">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<div class="space-y-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
||||
<div class="break-words">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
@@ -46,33 +45,25 @@ const useCopilotResponse = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Avatar
|
||||
name="Captain Copilot"
|
||||
icon-name="i-woot-captain"
|
||||
:size="24"
|
||||
rounded-full
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="messageContent"
|
||||
class="prose-sm break-words"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="messageContent"
|
||||
class="prose-sm break-words"
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
@click="useCopilotResponse"
|
||||
/>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
slate
|
||||
@click="useCopilotResponse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
|
||||
@submit.prevent="sendMessage"
|
||||
>
|
||||
<form class="relative" @submit.prevent="sendMessage">
|
||||
<input
|
||||
v-model="message"
|
||||
type="text"
|
||||
: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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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 { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline');
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
const statusList = computed(() => {
|
||||
return [
|
||||
@@ -46,6 +49,10 @@ const activeStatus = computed(() => {
|
||||
});
|
||||
|
||||
function changeAvailabilityStatus(availability) {
|
||||
if (isImpersonating.value) {
|
||||
useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.dispatch('updateAvailability', {
|
||||
availability,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useImpersonation } from 'dashboard/composables/useImpersonation';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||
@@ -20,6 +21,10 @@ export default {
|
||||
AvailabilityStatusBadge,
|
||||
NextButton,
|
||||
},
|
||||
setup() {
|
||||
const { isImpersonating } = useImpersonation();
|
||||
return { isImpersonating };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isStatusMenuOpened: false,
|
||||
@@ -73,6 +78,13 @@ export default {
|
||||
});
|
||||
},
|
||||
changeAvailabilityStatus(availability) {
|
||||
if (this.isImpersonating) {
|
||||
useAlert(
|
||||
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
+9
-6
@@ -38,7 +38,7 @@ const currentSortBy = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const chatStatusOptions = [
|
||||
const chatStatusOptions = computed(() => [
|
||||
{
|
||||
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
|
||||
value: 'open',
|
||||
@@ -59,9 +59,9 @@ const chatStatusOptions = [
|
||||
label: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
|
||||
value: 'all',
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const chatSortOptions = [
|
||||
const chatSortOptions = computed(() => [
|
||||
{
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.last_activity_at_asc.TEXT'),
|
||||
value: 'last_activity_at_asc',
|
||||
@@ -94,15 +94,18 @@ const chatSortOptions = [
|
||||
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_desc.TEXT'),
|
||||
value: 'waiting_since_desc',
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const activeChatStatusLabel = computed(
|
||||
() =>
|
||||
chatStatusOptions.find(m => m.value === chatStatusFilter.value)?.label || ''
|
||||
chatStatusOptions.value.find(m => m.value === chatStatusFilter.value)
|
||||
?.label || ''
|
||||
);
|
||||
|
||||
const activeChatSortLabel = computed(
|
||||
() => chatSortOptions.find(m => m.value === chatSortFilter.value)?.label || ''
|
||||
() =>
|
||||
chatSortOptions.value.find(m => m.value === chatSortFilter.value)?.label ||
|
||||
''
|
||||
);
|
||||
|
||||
const saveSelectedFilter = (type, value) => {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"TITLE": "Access Token",
|
||||
"DESCRIPTION": "Copy the access token and save it securely",
|
||||
"COPY_SUCCESSFUL": "Access token copied to clipboard"
|
||||
},
|
||||
"FORM": {
|
||||
"AVATAR": {
|
||||
"LABEL": "Bot avatar"
|
||||
|
||||
@@ -458,6 +458,10 @@
|
||||
"PLACEHOLDER": "Add Twitter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DELETE_CONTACT": {
|
||||
"MESSAGE": "This action is permanent and irreversible.",
|
||||
"BUTTON": "Delete now"
|
||||
}
|
||||
},
|
||||
"DETAILS": {
|
||||
@@ -467,7 +471,7 @@
|
||||
"DELETE_CONTACT": "Delete contact",
|
||||
"DELETE_DIALOG": {
|
||||
"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",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Contact deleted successfully",
|
||||
|
||||
@@ -327,12 +327,14 @@
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"COPILOT": {
|
||||
"TITLE": "Copilot",
|
||||
"SEND_MESSAGE": "Send message...",
|
||||
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
|
||||
"LOADER": "Captain is thinking",
|
||||
"YOU": "You",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset",
|
||||
"SHOW_STEPS": "Show steps",
|
||||
"SELECT_ASSISTANT": "Select Assistant",
|
||||
"PROMPTS": {
|
||||
"SUMMARIZE": {
|
||||
|
||||
@@ -185,7 +185,8 @@
|
||||
"OFFLINE": "Offline"
|
||||
},
|
||||
"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": {
|
||||
"LABEL": "Your email address",
|
||||
|
||||
+131
-45
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, helpers, url } from '@vuelidate/validators';
|
||||
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 NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
@@ -42,6 +45,9 @@ const formState = reactive({
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
|
||||
const [showAccessToken, toggleAccessToken] = useToggle();
|
||||
const accessToken = ref('');
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
botName: {
|
||||
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
|
||||
: uiFlags.value.isUpdating
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
const dialogTitle = computed(() => {
|
||||
if (showAccessToken.value) {
|
||||
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
|
||||
}
|
||||
|
||||
return props.type === MODAL_TYPES.CREATE
|
||||
? 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(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
|
||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const showAccessTokenInput = computed(
|
||||
() =>
|
||||
showAccessToken.value ||
|
||||
props.type === MODAL_TYPES.EDIT ||
|
||||
accessToken.value
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
botName: '',
|
||||
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
if (showAccessToken.value) return;
|
||||
|
||||
const botData = {
|
||||
name: formState.botName,
|
||||
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
|
||||
? botData
|
||||
: { id: props.selectedBot.id, data: botData };
|
||||
|
||||
await store.dispatch(
|
||||
const response = await store.dispatch(
|
||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||
actionPayload
|
||||
);
|
||||
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
|
||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||
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();
|
||||
} catch (error) {
|
||||
const errorKey = isCreate
|
||||
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
|
||||
|
||||
const initializeForm = () => {
|
||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||
const { name, description, outgoing_url, thumbnail, bot_config } =
|
||||
props.selectedBot;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
outgoing_url: botUrl,
|
||||
thumbnail,
|
||||
bot_config: botConfig,
|
||||
access_token: botAccessToken,
|
||||
} = props.selectedBot;
|
||||
formState.botName = name || '';
|
||||
formState.botDescription = description || '';
|
||||
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
|
||||
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
||||
formState.botAvatarUrl = thumbnail || '';
|
||||
|
||||
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
|
||||
accessToken.value = botAccessToken;
|
||||
}
|
||||
} else {
|
||||
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 });
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="v$.$reset()"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
<div
|
||||
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
: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>
|
||||
|
||||
<Input
|
||||
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
|
||||
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 v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
||||
<label
|
||||
v-if="type === MODAL_TYPES.EDIT"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||
</label>
|
||||
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||
@click="dialogRef.close()"
|
||||
@click="onClickClose()"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="!showAccessToken"
|
||||
type="submit"
|
||||
data-testid="label-submit"
|
||||
:label="confirmButtonLabel"
|
||||
|
||||
@@ -39,6 +39,7 @@ const onClick = () => {
|
||||
<template #masked>
|
||||
<button
|
||||
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
|
||||
type="button"
|
||||
@click="toggleMasked"
|
||||
>
|
||||
<fluent-icon :icon="maskIcon" :size="16" />
|
||||
@@ -46,7 +47,7 @@ const onClick = () => {
|
||||
</template>
|
||||
</woot-input>
|
||||
<FormButton
|
||||
type="submit"
|
||||
type="button"
|
||||
size="large"
|
||||
icon="text-copy"
|
||||
variant="outline"
|
||||
|
||||
@@ -2,6 +2,8 @@ import types from '../mutation-types';
|
||||
import authAPI from '../../api/auth';
|
||||
|
||||
import { setUser, clearCookiesOnLogout } from '../utils/api';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
|
||||
const initialState = {
|
||||
currentUser: {
|
||||
@@ -145,8 +147,15 @@ export const actions = {
|
||||
updateUISettings: async ({ commit }, params) => {
|
||||
try {
|
||||
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) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import differenceInDays from 'date-fns/differenceInDays';
|
||||
import Cookies from 'js-cookie';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import {
|
||||
ANALYTICS_IDENTITY,
|
||||
@@ -44,6 +46,10 @@ export const clearLocalStorageOnLogout = () => {
|
||||
LocalStorage.remove(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES);
|
||||
};
|
||||
|
||||
export const clearSessionStorageOnLogout = () => {
|
||||
SessionStorage.remove(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
|
||||
};
|
||||
|
||||
export const deleteIndexedDBOnLogout = async () => {
|
||||
let dbs = [];
|
||||
try {
|
||||
@@ -75,6 +81,7 @@ export const clearCookiesOnLogout = () => {
|
||||
emitter.emit(ANALYTICS_RESET);
|
||||
clearBrowserSessionCookies();
|
||||
clearLocalStorageOnLogout();
|
||||
clearSessionStorageOnLogout();
|
||||
const globalConfig = window.globalConfig || {};
|
||||
const logoutRedirectLink = globalConfig.LOGOUT_REDIRECT_LINK || '/';
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { parseBoolean } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
// mixins
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
|
||||
@@ -21,6 +22,8 @@ const ERROR_MESSAGES = {
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
@@ -112,6 +115,14 @@ export default {
|
||||
this.loginApi.message = 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() {
|
||||
this.loginApi.hasErrored = false;
|
||||
this.loginApi.showLoading = true;
|
||||
@@ -128,6 +139,7 @@ export default {
|
||||
|
||||
login(credentials)
|
||||
.then(() => {
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
.catch(response => {
|
||||
|
||||
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# checks if follow this pattern send it to reply_mailbox
|
||||
|
||||
@@ -97,8 +97,8 @@ class Account < ApplicationRecord
|
||||
|
||||
has_one_attached :contacts_export
|
||||
|
||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
||||
enum status: { active: 0, suspended: 1 }
|
||||
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||
enum :status, { active: 0, suspended: 1 }
|
||||
|
||||
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}"
|
||||
end
|
||||
|
||||
def generate_sso_link_with_impersonation
|
||||
"#{generate_sso_link}&impersonation=true"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sso_token_key(token)
|
||||
|
||||
@@ -128,6 +128,12 @@ class Conversation < ApplicationRecord
|
||||
additional_attributes&.dig('conversation_language')
|
||||
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
|
||||
self[:last_activity_at] || created_at
|
||||
end
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
|
||||
#
|
||||
class EmailTemplate < ApplicationRecord
|
||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
||||
enum template_type: { layout: 0, content: 1 }
|
||||
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
|
||||
enum :template_type, { layout: 0, content: 1 }
|
||||
belongs_to :account, optional: true
|
||||
|
||||
validates :name, uniqueness: { scope: :account }
|
||||
|
||||
@@ -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
|
||||
# FIX ME : fixes breakage of installation config. we need to migrate.
|
||||
# Fix configuration in application.rb
|
||||
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
|
||||
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||
|
||||
before_validation :set_lock
|
||||
validates :name, presence: true
|
||||
@@ -32,6 +32,10 @@ class InstallationConfig < ApplicationRecord
|
||||
after_commit :clear_cache
|
||||
|
||||
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]
|
||||
end
|
||||
|
||||
|
||||
@@ -185,7 +185,13 @@ class Message < ApplicationRecord
|
||||
# move this to a presenter
|
||||
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
|
||||
|
||||
def email_notifiable_message?
|
||||
|
||||
+2
-2
@@ -109,8 +109,8 @@ class User < ApplicationRecord
|
||||
self.email = email.try(:downcase)
|
||||
end
|
||||
|
||||
def send_devise_notification(notification, *args)
|
||||
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later
|
||||
def send_devise_notification(notification, *)
|
||||
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
|
||||
end
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
@user,
|
||||
|
||||
@@ -8,9 +8,23 @@ class Conversations::PermissionFilterService
|
||||
end
|
||||
|
||||
def perform
|
||||
# The base implementation simply returns all conversations
|
||||
# Enterprise edition extends this with permission-based filtering
|
||||
conversations
|
||||
return conversations if user_role == 'administrator'
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -31,7 +31,11 @@ class Crm::Leadsquared::LeadFinderService
|
||||
def find_by_phone_number(contact)
|
||||
return if contact.phone_number.blank?
|
||||
|
||||
search_by_field(contact.phone_number)
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
|
||||
return if lead_data.blank? || lead_data['Mobile'].nil?
|
||||
|
||||
search_by_field(lead_data['Mobile'])
|
||||
end
|
||||
|
||||
def search_by_field(value)
|
||||
|
||||
@@ -20,11 +20,29 @@ class Crm::Leadsquared::Mappers::ContactMapper
|
||||
'FirstName' => contact.name.presence,
|
||||
'LastName' => contact.last_name.presence,
|
||||
'EmailAddress' => contact.email.presence,
|
||||
'Mobile' => contact.phone_number.presence,
|
||||
'Mobile' => formatted_phone_number,
|
||||
'Source' => brand_name
|
||||
}.compact
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
# it seems like leadsquared needs a different phone number format
|
||||
# it's not documented anywhere, so don't bother trying to look up online
|
||||
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
|
||||
return nil if contact.phone_number.blank?
|
||||
|
||||
parsed = TelephoneNumber.parse(contact.phone_number)
|
||||
return contact.phone_number unless parsed.valid?
|
||||
|
||||
country_code = parsed.country.country_code
|
||||
e164 = parsed.e164_number
|
||||
e164 = e164.sub(/^\+/, '')
|
||||
|
||||
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
|
||||
|
||||
"+#{country_code}-#{national_number}"
|
||||
end
|
||||
|
||||
def brand_name
|
||||
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
|
||||
end
|
||||
|
||||
@@ -12,7 +12,6 @@ class MessageTemplates::Template::CsatSurvey
|
||||
private
|
||||
|
||||
delegate :contact, :account, :inbox, to: :conversation
|
||||
delegate :csat_config, to: :inbox
|
||||
|
||||
def should_send_csat_survey?
|
||||
return true unless survey_rules_configured?
|
||||
|
||||
@@ -137,14 +137,19 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def download_with_auth(media_url)
|
||||
Down.download(
|
||||
media_url,
|
||||
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
|
||||
)
|
||||
auth_credentials = if twilio_channel.api_key_sid.present?
|
||||
# When using api_key_sid, the auth token should be the api_secret_key
|
||||
[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
|
||||
|
||||
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)
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
json.access_token @resource.access_token.token
|
||||
json.expiry nil
|
||||
json.user do
|
||||
json.id @resource.id
|
||||
json.name @resource.name
|
||||
json.display_name @resource.display_name
|
||||
json.email @resource.email
|
||||
json.pubsub_token @resource.pubsub_token
|
||||
end
|
||||
@@ -2,7 +2,7 @@
|
||||
<hr/>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.1.0'
|
||||
version: '4.2.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# TODO: Phase out the custom ConnectionPool wrappers ($alfred / $velma),
|
||||
# switch to plain Redis clients here and let Rails 7.1+ handle pooling
|
||||
# via `pool:` in RedisCacheStore (see rack_attack initializer).
|
||||
|
||||
# Alfred
|
||||
# Add here as you use it for more features
|
||||
# Used for Round Robin, Conversation Emails & Online Presence
|
||||
|
||||
@@ -14,6 +14,10 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || Rails.env.development?
|
||||
resource '*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
|
||||
end
|
||||
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_API_CORS', false))
|
||||
resource '/api/*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ class Rack::Attack
|
||||
# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
# https://github.com/rack/rack-attack/issues/102
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma)
|
||||
# Rails 7.1 automatically adds its own ConnectionPool around RedisCacheStore.
|
||||
# Because `$velma` is *already* a ConnectionPool, double-wrapping causes
|
||||
# Redis calls like `get` to hit the outer wrapper and explode.
|
||||
# `pool: false` tells Rails to skip its internal pool and use ours directly.
|
||||
# TODO: We can use build in connection pool in future upgrade
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
|
||||
|
||||
class Request < ::Rack::Request
|
||||
# You many need to specify a method to fetch the correct remote IP address
|
||||
|
||||
@@ -396,6 +396,7 @@ Rails.application.routes.draw do
|
||||
resources :users, only: [:create, :show, :update, :destroy] do
|
||||
member do
|
||||
get :login
|
||||
post :token
|
||||
end
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
|
||||
@@ -16,10 +16,10 @@ KillMode=mixed
|
||||
StandardInput=null
|
||||
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="RAILS_ENV=production"
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="RAILS_LOG_TO_STDOUT=true"
|
||||
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
|
||||
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
|
||||
|
||||
@@ -21,10 +21,10 @@ MemoryHigh=1.4G
|
||||
MemorySwapMax=0
|
||||
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="RAILS_ENV=production"
|
||||
Environment="NODE_ENV=production"
|
||||
Environment="RAILS_LOG_TO_STDOUT=true"
|
||||
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
|
||||
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
|
||||
|
||||
@@ -338,8 +338,8 @@ function setup_chatwoot() {
|
||||
sudo -i -u chatwoot << EOF
|
||||
rvm --version
|
||||
rvm autolibs disable
|
||||
rvm install "ruby-3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
rvm install "ruby-3.4.4"
|
||||
rvm use 3.4.4 --default
|
||||
|
||||
git clone https://github.com/chatwoot/chatwoot.git
|
||||
cd chatwoot
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# pre-build stage
|
||||
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 PNPM_VERSION="10.2.0"
|
||||
@@ -90,13 +90,13 @@ RUN if [ "$RAILS_ENV" = "production" ]; then \
|
||||
RUN git rev-parse HEAD > /app/.git_sha
|
||||
|
||||
# Remove unnecessary files
|
||||
RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
|
||||
&& find /gems/ruby/3.3.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
|
||||
RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
|
||||
&& find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
|
||||
&& rm -rf .git \
|
||||
&& rm .gitignore
|
||||
|
||||
# final build stage
|
||||
FROM ruby:3.3.3-alpine3.19
|
||||
FROM ruby:3.4.4-alpine3.21
|
||||
|
||||
ARG NODE_VERSION="23.7.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
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@custom_role = Current.account.custom_roles.create!(permitted_params)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@custom_role.update!(permitted_params)
|
||||
end
|
||||
|
||||
@@ -6,12 +6,12 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
|
||||
@sla_policies = Current.account.sla_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@sla_policy = Current.account.sla_policies.create!(permitted_params)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@sla_policy.update!(permitted_params)
|
||||
end
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
module Captain::ChatHelper
|
||||
def search_documentation_tool
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_documentation',
|
||||
description: "Use this function to get documentation on functionalities you don't know about.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
search_query: {
|
||||
type: 'string',
|
||||
description: 'The search query to look up in the documentation.'
|
||||
}
|
||||
},
|
||||
required: ['search_query']
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def request_chat_completion
|
||||
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" }
|
||||
|
||||
@@ -26,13 +6,12 @@ module Captain::ChatHelper
|
||||
parameters: {
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: [search_documentation_tool],
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' }
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
@response
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
@@ -41,7 +20,7 @@ module Captain::ChatHelper
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
@response = JSON.parse(message['content'].strip)
|
||||
JSON.parse(message['content'].strip)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,38 +33,20 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if tool_call['function']['name'] == 'search_documentation'
|
||||
query = JSON.parse(tool_call['function']['arguments'])['search_query']
|
||||
sections = fetch_documentation(query)
|
||||
append_tool_response(sections, tool_call_id)
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
append_tool_response('', tool_call_id)
|
||||
process_invalid_tool_call(tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_documentation(query)
|
||||
Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
|
||||
@assistant
|
||||
.responses
|
||||
.approved
|
||||
.search(query)
|
||||
.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
result = @tool_registry.send(function_name, arguments)
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@@ -95,11 +56,15 @@ module Captain::ChatHelper
|
||||
}
|
||||
end
|
||||
|
||||
def append_tool_response(sections, tool_call_id)
|
||||
def process_invalid_tool_call(tool_call_id)
|
||||
append_tool_response('Tool not available', tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: "Found the following FAQs in the documentation:\n #{sections}"
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def message_content(message)
|
||||
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'
|
||||
end
|
||||
|
||||
@@ -10,6 +10,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@conversation_history = config[:conversation_history]
|
||||
@previous_messages = config[:previous_messages] || []
|
||||
@language = config[:language] || 'english'
|
||||
|
||||
register_tools
|
||||
@messages = [system_message, conversation_history_context] + @previous_messages
|
||||
@response = ''
|
||||
end
|
||||
@@ -25,6 +27,11 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
|
||||
private
|
||||
|
||||
def register_tools
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant)
|
||||
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
|
||||
end
|
||||
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
|
||||
@@ -9,6 +9,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
@assistant = assistant
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
register_tools
|
||||
end
|
||||
|
||||
def generate_response(input, previous_messages = [], role = 'user')
|
||||
@@ -19,6 +20,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
|
||||
private
|
||||
|
||||
def register_tools
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant)
|
||||
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
|
||||
end
|
||||
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class Captain::ToolRegistryService
|
||||
attr_reader :registered_tools, :tools
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
@registered_tools = []
|
||||
@tools = {}
|
||||
end
|
||||
|
||||
def register_tool(tool_class)
|
||||
tool = tool_class.new(@assistant, user: @user)
|
||||
return unless tool.active?
|
||||
|
||||
@tools[tool.name] = tool
|
||||
@registered_tools << tool.to_registry_format
|
||||
end
|
||||
|
||||
def method_missing(method_name, *)
|
||||
if @tools.key?(method_name.to_s)
|
||||
@tools[method_name.to_s].execute(*)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def respond_to_missing?(method_name, include_private = false)
|
||||
@tools.key?(method_name.to_s) || super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
class Captain::Tools::BaseService
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
end
|
||||
|
||||
def name
|
||||
raise NotImplementedError, "#{self.class} must implement name"
|
||||
end
|
||||
|
||||
def description
|
||||
raise NotImplementedError, "#{self.class} must implement description"
|
||||
end
|
||||
|
||||
def parameters
|
||||
raise NotImplementedError, "#{self.class} must implement parameters"
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
raise NotImplementedError, "#{self.class} must implement execute"
|
||||
end
|
||||
|
||||
def to_registry_format
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: name,
|
||||
description: description,
|
||||
parameters: parameters
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_has_permission(permission)
|
||||
return false if @user.blank?
|
||||
|
||||
account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id)
|
||||
return false if account_user.blank?
|
||||
|
||||
return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present?
|
||||
|
||||
# Default permission for agents without custom roles
|
||||
account_user.administrator? || account_user.agent?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService
|
||||
def name
|
||||
'get_article'
|
||||
end
|
||||
|
||||
def description
|
||||
'Get details of an article including its content and metadata'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
article_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the article to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[article_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
article_id = arguments['article_id']
|
||||
|
||||
Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" }
|
||||
|
||||
return 'Missing required parameters' if article_id.blank?
|
||||
|
||||
article = Article.find_by(id: article_id, account_id: @assistant.account_id)
|
||||
return 'Article not found' if article.nil?
|
||||
|
||||
article.to_llm_text
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('knowledge_base_manage')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService
|
||||
def name
|
||||
'get_contact'
|
||||
end
|
||||
|
||||
def description
|
||||
'Get details of a contact including their profile information'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the contact to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[contact_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
contact_id = arguments['contact_id']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}"
|
||||
|
||||
return 'Missing required parameters' if contact_id.blank?
|
||||
|
||||
contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id)
|
||||
return 'Contact not found' if contact.nil?
|
||||
|
||||
contact.to_llm_text
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('contact_manage')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService
|
||||
def name
|
||||
'get_conversation'
|
||||
end
|
||||
|
||||
def description
|
||||
'Get details of a conversation including messages and contact information'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversation_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the conversation to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[conversation_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
conversation_id = arguments['conversation_id']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}"
|
||||
|
||||
return 'Missing required parameters' if conversation_id.blank?
|
||||
|
||||
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
|
||||
return 'Conversation not found' if conversation.blank?
|
||||
|
||||
conversation.to_llm_text
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('conversation_manage') ||
|
||||
user_has_permission('conversation_unassigned_manage') ||
|
||||
user_has_permission('conversation_participating_manage')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_articles'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search articles based on parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: ['query']
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
query = arguments['query']
|
||||
category_id = arguments['category_id']
|
||||
status = arguments['status']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}"
|
||||
|
||||
return 'Missing required parameters' if query.blank?
|
||||
|
||||
articles = fetch_articles(query, category_id, status)
|
||||
|
||||
return 'No articles found' unless articles.exists?
|
||||
|
||||
total_count = articles.count
|
||||
articles = articles.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
|
||||
#{articles.map(&:to_llm_text).join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('knowledge_base_manage')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_articles(query, category_id, status)
|
||||
articles = Article.where(account_id: @assistant.account_id)
|
||||
articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
|
||||
articles = articles.where(category_id: category_id) if category_id.present?
|
||||
articles = articles.where(status: status) if status.present?
|
||||
articles
|
||||
end
|
||||
|
||||
def properties
|
||||
{
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search articles by title or content (partial match)'
|
||||
},
|
||||
category_id: {
|
||||
type: 'number',
|
||||
description: 'Filter articles by category ID'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: %w[draft published archived],
|
||||
description: 'Filter articles by status'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_contacts'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search contacts based on query parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: []
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
email = arguments['email']
|
||||
phone_number = arguments['phone_number']
|
||||
name = arguments['name']
|
||||
|
||||
Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}"
|
||||
|
||||
contacts = Contact.where(account_id: @assistant.account_id)
|
||||
contacts = contacts.where(email: email) if email.present?
|
||||
contacts = contacts.where(phone_number: phone_number) if phone_number.present?
|
||||
contacts = contacts.where('LOWER(name) ILIKE ?', "%#{name.downcase}%") if name.present?
|
||||
|
||||
return 'No contacts found' unless contacts.exists?
|
||||
|
||||
contacts = contacts.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{contacts.map(&:to_llm_text).join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('contact_manage')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def properties
|
||||
{
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by email'
|
||||
},
|
||||
phone_number: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by phone number'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by name (partial match)'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
|
||||
def active?
|
||||
user_has_permission('conversation_manage') ||
|
||||
user_has_permission('conversation_unassigned_manage') ||
|
||||
user_has_permission('conversation_participating_manage')
|
||||
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
|
||||
@@ -0,0 +1,77 @@
|
||||
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_linear_issues'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search Linear issues based on a search term'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
return 'Linear integration is not enabled' unless active?
|
||||
|
||||
term = arguments['term']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
|
||||
|
||||
return 'Missing required parameters' if term.blank?
|
||||
|
||||
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
|
||||
result = linear_service.search_issue(term)
|
||||
|
||||
return result[:error] if result[:error]
|
||||
|
||||
issues = result[:data]
|
||||
return 'No issues found, I should try another similar search term' if issues.blank?
|
||||
|
||||
total_count = issues.length
|
||||
|
||||
<<~RESPONSE
|
||||
Total number of issues: #{total_count}
|
||||
#{issues.map { |issue| format_issue(issue) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
@assistant.account.hooks.find_by(app_id: 'linear').present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_issue(issue)
|
||||
<<~ISSUE
|
||||
Title: #{issue['title']}
|
||||
ID: #{issue['id']}
|
||||
State: #{issue['state']['name']}
|
||||
Priority: #{format_priority(issue['priority'])}
|
||||
#{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'}
|
||||
#{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''}
|
||||
ISSUE
|
||||
end
|
||||
|
||||
def format_priority(priority)
|
||||
return 'No priority' if priority.nil?
|
||||
|
||||
case priority
|
||||
when 0 then 'No priority'
|
||||
when 1 then 'Urgent'
|
||||
when 2 then 'High'
|
||||
when 3 then 'Medium'
|
||||
when 4 then 'Low'
|
||||
else 'Unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_documentation'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search and retrieve documentation from knowledge base'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
search_query: {
|
||||
type: 'string',
|
||||
description: 'The search query to look up in the documentation.'
|
||||
}
|
||||
},
|
||||
required: ['search_query']
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
query = arguments['search_query']
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
responses = assistant.responses.approved.search(query)
|
||||
|
||||
return 'No FAQs found for the given query' if responses.empty?
|
||||
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_response(response)
|
||||
formatted_response = "
|
||||
Question: #{response.question}
|
||||
Answer: #{response.answer}
|
||||
"
|
||||
if response.documentable.present? && response.documentable.try(:external_link)
|
||||
formatted_response += "
|
||||
Source: #{response.documentable.external_link}
|
||||
"
|
||||
end
|
||||
|
||||
formatted_response
|
||||
end
|
||||
end
|
||||
@@ -1,36 +1,37 @@
|
||||
module Enterprise::Conversations::PermissionFilterService
|
||||
def perform
|
||||
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
|
||||
permissions = account_user&.permissions || []
|
||||
user_role = account_user&.role
|
||||
return filter_by_permissions(permissions) if user_has_custom_role?
|
||||
|
||||
# Skip filtering for administrators
|
||||
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)
|
||||
super
|
||||
end
|
||||
|
||||
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)
|
||||
# Permission-based filtering with hierarchy
|
||||
# conversation_manage > conversation_unassigned_manage > conversation_participating_manage
|
||||
if permissions.include?('conversation_manage')
|
||||
conversations
|
||||
accessible_conversations
|
||||
elsif permissions.include?('conversation_unassigned_manage')
|
||||
filter_unassigned_and_mine
|
||||
elsif permissions.include?('conversation_participating_manage')
|
||||
conversations.assigned_to(user)
|
||||
accessible_conversations.assigned_to(user)
|
||||
else
|
||||
Conversation.none
|
||||
end
|
||||
end
|
||||
|
||||
def filter_unassigned_and_mine
|
||||
mine = conversations.assigned_to(user)
|
||||
unassigned = conversations.unassigned
|
||||
mine = accessible_conversations.assigned_to(user)
|
||||
unassigned = accessible_conversations.unassigned
|
||||
|
||||
Conversation.from("(#{mine.to_sql} UNION #{unassigned.to_sql}) as conversations")
|
||||
.where(account_id: account.id)
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ class Linear
|
||||
assigneeId: params[:assignee_id],
|
||||
priority: params[:priority],
|
||||
labelIds: params[:label_ids],
|
||||
projectId: params[:project_id]
|
||||
projectId: params[:project_id],
|
||||
stateId: params[:state_id]
|
||||
}.compact
|
||||
mutation = Linear::Mutations.issue_create(variables)
|
||||
response = post({ query: mutation })
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
@@ -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
|
||||
@@ -79,6 +79,18 @@ describe Messages::Instagram::MessageBuilder do
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
end
|
||||
|
||||
it 'discards duplicate messages from webhook events with the same message_id' do
|
||||
messaging = dm_params[:entry][0]['messaging'][0]
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
|
||||
initial_message_count = instagram_inbox.messages.count
|
||||
expect(initial_message_count).to be 1
|
||||
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
|
||||
expect(instagram_inbox.messages.count).to eq initial_message_count
|
||||
end
|
||||
|
||||
it 'creates message for shared reel' do
|
||||
messaging = shared_reel_params[:entry][0]['messaging'][0]
|
||||
described_class.new(messaging, instagram_inbox).perform
|
||||
@@ -151,11 +163,15 @@ describe Messages::Instagram::MessageBuilder do
|
||||
end
|
||||
|
||||
it 'does not create message for unsupported file type' do
|
||||
conversation
|
||||
|
||||
# try to create a message with unsupported file type
|
||||
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||
|
||||
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
|
||||
|
||||
# Conversation should exist but no new message should be created
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 0
|
||||
end
|
||||
|
||||
@@ -189,19 +189,22 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
||||
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
conversation
|
||||
|
||||
# create a message with unsupported file type
|
||||
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
|
||||
messaging = story_mention_params[:entry][0][:messaging][0]
|
||||
contact_inbox
|
||||
|
||||
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
|
||||
# we would have contact created but message and attachments won't be created
|
||||
# Conversation should exist but no new message should be created
|
||||
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.count).to be 0
|
||||
|
||||
contact = instagram_messenger_channel.inbox.contacts.first
|
||||
|
||||
expect(contact.name).to eq('Jane Dae')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
|
||||
end
|
||||
|
||||
describe '#timeseries' do
|
||||
include_examples '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', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
include_examples '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', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
|
||||
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
end
|
||||
|
||||
@@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
description: 'This is a sample issue.',
|
||||
assignee_id: 'user1',
|
||||
priority: 'high',
|
||||
state_id: 'state1',
|
||||
label_ids: ['label1']
|
||||
}
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
let(:account_builder) { double }
|
||||
let(:user_double) { object_double(:user) }
|
||||
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
|
||||
|
||||
def set_omniauth_config(for_email = 'test@example.com')
|
||||
OmniAuth.config.test_mode = true
|
||||
@@ -17,13 +18,18 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
|
||||
end
|
||||
|
||||
describe '#omniauth_sucess' 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')
|
||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
||||
allow(account_builder).to receive(:perform).and_return(user_double)
|
||||
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'
|
||||
|
||||
@@ -43,8 +49,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
end
|
||||
|
||||
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')
|
||||
allow(email_validation_service).to receive(:perform).and_raise(CustomExceptions::Account::InvalidEmail.new({ valid: false, disposable: nil }))
|
||||
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
|
||||
# expect a 302 redirect to auth/google_oauth2/callback
|
||||
@@ -57,10 +65,13 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
end
|
||||
|
||||
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
|
||||
['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |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'
|
||||
|
||||
# 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
|
||||
# does not allow any signup if the ENV explicitly disables it
|
||||
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')
|
||||
allow(email_validation_service).to receive(:perform).and_return(true)
|
||||
|
||||
get '/omniauth/google_oauth2/callback'
|
||||
|
||||
# expect a 302 redirect to auth/google_oauth2/callback
|
||||
@@ -90,38 +103,42 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
|
||||
end
|
||||
|
||||
it 'allows login' do
|
||||
create(:user, email: 'test@example.com')
|
||||
set_omniauth_config('test@example.com')
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'test@example.com')
|
||||
set_omniauth_config('test@example.com')
|
||||
|
||||
get '/omniauth/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')
|
||||
get '/omniauth/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')
|
||||
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# expect app/login page to respond with 200 and render
|
||||
follow_redirect!
|
||||
expect(response).to have_http_status(:ok)
|
||||
# expect app/login page to respond with 200 and render
|
||||
follow_redirect!
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
# 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
|
||||
# we need to test this explicitly
|
||||
it 'allows personal account login' do
|
||||
create(:user, email: 'personal-existing@gmail.com')
|
||||
set_omniauth_config('personal-existing@gmail.com')
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'personal-existing@gmail.com')
|
||||
set_omniauth_config('personal-existing@gmail.com')
|
||||
|
||||
get '/omniauth/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')
|
||||
get '/omniauth/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')
|
||||
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
follow_redirect!
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# expect app/login page to respond with 200 and render
|
||||
follow_redirect!
|
||||
expect(response).to have_http_status(:ok)
|
||||
# expect app/login page to respond with 200 and render
|
||||
follow_redirect!
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,6 +76,54 @@ RSpec.describe 'Platform Users API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /platform/api/v1/users/{user_id}/token' do
|
||||
context 'when it is an unauthenticated platform app' do
|
||||
it 'returns unauthorized' do
|
||||
post "/platform/api/v1/users/#{user.id}/token"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an invalid platform app token' do
|
||||
it 'returns unauthorized' do
|
||||
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: 'invalid' }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated platform app' do
|
||||
let(:platform_app) { create(:platform_app) }
|
||||
|
||||
it 'returns unauthorized when its not a permissible object' do
|
||||
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns access token for the user with expiry and user info' do
|
||||
create(:platform_app_permissible, platform_app: platform_app, permissible: user)
|
||||
|
||||
post "/platform/api/v1/users/#{user.id}/token",
|
||||
headers: { api_access_token: platform_app.access_token.token }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
data = response.parsed_body
|
||||
|
||||
# Check access token and expiry
|
||||
expect(data['access_token']).to eq(user.access_token.token)
|
||||
expect(data['expiry']).to be_nil
|
||||
|
||||
# Check user info
|
||||
expect(data['user']).to include(
|
||||
'id' => user.id,
|
||||
'name' => user.name,
|
||||
'display_name' => user.display_name,
|
||||
'email' => user.email,
|
||||
'pubsub_token' => user.pubsub_token
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /platform/api/v1/users/' do
|
||||
context 'when it is an unauthenticated platform app' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# Test tool implementation
|
||||
class TestTool < Captain::Tools::BaseService
|
||||
attr_accessor :tool_active
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
super
|
||||
@tool_active = true
|
||||
end
|
||||
|
||||
def name
|
||||
'test_tool'
|
||||
end
|
||||
|
||||
def description
|
||||
'A test tool for specs'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def execute(*args)
|
||||
args
|
||||
end
|
||||
|
||||
def active?
|
||||
@tool_active
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe Captain::ToolRegistryService do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:service) { described_class.new(assistant) }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with empty tools and registered_tools' do
|
||||
expect(service.tools).to be_empty
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#register_tool' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
context 'when tool is active' do
|
||||
it 'registers a new tool' do
|
||||
service.register_tool(tool_class)
|
||||
expect(service.tools['test_tool']).to be_a(TestTool)
|
||||
expect(service.registered_tools).to include(
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'A test tool for specs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
test_param: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tool is inactive' do
|
||||
it 'does not register the tool' do
|
||||
tool = tool_class.new(assistant)
|
||||
tool.tool_active = false
|
||||
allow(tool_class).to receive(:new).and_return(tool)
|
||||
|
||||
service.register_tool(tool_class)
|
||||
|
||||
expect(service.tools['test_tool']).to be_nil
|
||||
expect(service.registered_tools).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'method_missing' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
before do
|
||||
service.register_tool(tool_class)
|
||||
end
|
||||
|
||||
context 'when method corresponds to a registered tool' do
|
||||
it 'executes the tool with given arguments' do
|
||||
result = service.test_tool(test_param: 'arg1')
|
||||
expect(result).to eq([{ test_param: 'arg1' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when method does not correspond to a registered tool' do
|
||||
it 'raises NoMethodError' do
|
||||
expect { service.unknown_tool }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, 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('get_article')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of an article including its content and metadata')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
article_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the article to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[article_id]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without knowledge_base_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when article_id is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({})).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'article_id' => 999 })).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article exists' do
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
|
||||
|
||||
it 'returns the article in llm text format' do
|
||||
result = service.execute({ 'article_id' => article.id })
|
||||
expect(result).to eq(article.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when article belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_portal) { create(:portal, account: other_account) }
|
||||
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetContactService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, 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('get_contact')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of a contact including their profile information')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the contact to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[contact_id]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without contact_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when contact_id is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({})).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact exists' do
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
|
||||
it 'returns the contact in llm text format' do
|
||||
result = service.execute({ 'contact_id' => contact.id })
|
||||
expect(result).to eq(contact.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when contact belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_contact) { create(:contact, account: other_account) }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::GetConversationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, 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('get_conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Get details of a conversation including messages and contact information')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversation_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the conversation to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[conversation_id]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user is an admin' do
|
||||
let(:user) { create(:user, :administrator, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_unassigned_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role with conversation_participating_manage permission' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(service.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has custom role without any conversation permissions' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
|
||||
before do
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
account_user.update(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.active?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when conversation_id is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({})).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation exists' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
it 'returns the conversation in llm text format' do
|
||||
result = service.execute({ 'conversation_id' => conversation.display_id })
|
||||
expect(result).to eq(conversation.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when conversation belongs to different account' do
|
||||
let(:other_account) { create(:account) }
|
||||
let(:other_inbox) { create(:inbox, account: other_account) }
|
||||
let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user