Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
648b9c9507 | ||
|
|
611fc82847 | ||
|
|
ffad8458cb | ||
|
|
5869a98e6e | ||
|
|
dde08b0bd9 | ||
|
|
5a4201ae58 | ||
|
|
fc8fe9a8d1 | ||
|
|
2fe0761fd5 | ||
|
|
9dd2d14655 |
@@ -73,15 +73,15 @@ jobs:
|
||||
libvips
|
||||
|
||||
- run:
|
||||
name: Install RVM and Ruby 3.4.4
|
||||
name: Install RVM and Ruby 3.3.3
|
||||
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.4.4"
|
||||
rvm use 3.4.4 --default
|
||||
rvm install "3.3.3"
|
||||
rvm use 3.3.3 --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.4.4'
|
||||
RUBY_VERSION: '3.3.3'
|
||||
# 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.4.4'
|
||||
RUBY_VERSION: '3.3.3'
|
||||
# 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'
|
||||
|
||||
+8
-179
@@ -1,10 +1,7 @@
|
||||
plugins:
|
||||
require:
|
||||
- rubocop-performance
|
||||
- rubocop-rails
|
||||
- rubocop-rspec
|
||||
- rubocop-factory_bot
|
||||
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
|
||||
@@ -16,61 +13,44 @@ 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:
|
||||
@@ -78,16 +58,10 @@ 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'
|
||||
@@ -97,101 +71,74 @@ 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
|
||||
|
||||
@@ -219,121 +166,3 @@ 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.4.4
|
||||
3.3.3
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.4.4'
|
||||
ruby '3.3.3'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', '2.0.0', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.1'
|
||||
gem 'rails', '~> 7.0.8.4'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -33,8 +33,6 @@ gem 'liquid'
|
||||
gem 'commonmarker'
|
||||
# Validate Data against JSON Schema
|
||||
gem 'json_schemer'
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
# Rack middleware for blocking & throttling abusive requests
|
||||
gem 'rack-attack', '>= 6.7.0'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
@@ -198,6 +196,9 @@ group :development do
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console', '>= 4.2.1'
|
||||
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
|
||||
# When we want to squash migrations
|
||||
gem 'squasher'
|
||||
|
||||
@@ -236,7 +237,6 @@ 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
|
||||
|
||||
+141
-161
@@ -25,89 +25,76 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actioncable (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
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)
|
||||
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)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
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)
|
||||
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)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
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)
|
||||
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)
|
||||
rack-test (>= 0.6.3)
|
||||
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)
|
||||
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)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
actionview (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
activejob (7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
globalid (>= 0.3.6)
|
||||
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)
|
||||
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)
|
||||
activerecord (>= 4.2)
|
||||
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)
|
||||
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)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.5.1)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.7)
|
||||
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 (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
acts-as-taggable-on (9.0.1)
|
||||
activerecord (>= 6.0, < 7.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
administrate (0.20.1)
|
||||
@@ -129,7 +116,7 @@ GEM
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ast (2.4.3)
|
||||
ast (2.4.2)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
activerecord (>= 5.0, < 7.7)
|
||||
@@ -155,15 +142,14 @@ GEM
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
bigdecimal (3.1.9)
|
||||
bigdecimal (3.1.8)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (5.4.1)
|
||||
browser (5.3.1)
|
||||
builder (3.3.0)
|
||||
bullet (8.0.7)
|
||||
bullet (7.0.7)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundle-audit (0.1.0)
|
||||
@@ -175,8 +161,8 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
@@ -190,10 +176,16 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
ddtrace (0.48.0)
|
||||
ffi (~> 1.0)
|
||||
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)
|
||||
msgpack
|
||||
debase-ruby_core_source (3.3.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -204,10 +196,10 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise_token_auth (1.2.5)
|
||||
devise_token_auth (1.2.3)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 8.1)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -220,7 +212,6 @@ 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)
|
||||
@@ -263,10 +254,7 @@ GEM
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
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 (1.16.3)
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
@@ -327,13 +315,16 @@ GEM
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.25.7)
|
||||
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)
|
||||
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.20.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
@@ -343,17 +334,17 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
groupdate (6.2.1)
|
||||
activesupport (>= 5.2)
|
||||
grpc (1.72.0)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
grpc (1.62.0)
|
||||
google-protobuf (~> 3.25)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.72.0-arm64-darwin)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
grpc (1.62.0-arm64-darwin)
|
||||
google-protobuf (~> 3.25)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.72.0-x86_64-darwin)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
grpc (1.62.0-x86_64-darwin)
|
||||
google-protobuf (~> 3.25)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
grpc (1.72.0-x86_64-linux)
|
||||
google-protobuf (>= 3.25, < 5.0)
|
||||
grpc (1.62.0-x86_64-linux)
|
||||
google-protobuf (~> 3.25)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.1)
|
||||
hairtrigger (1.0.0)
|
||||
@@ -379,7 +370,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.7)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -397,7 +388,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.12.0)
|
||||
json (2.6.3)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -432,13 +423,21 @@ 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)
|
||||
@@ -446,7 +445,7 @@ GEM
|
||||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.7.0)
|
||||
logger (1.6.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -472,10 +471,10 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
minitest (5.25.4)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.7.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
@@ -546,16 +545,14 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.1)
|
||||
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)
|
||||
@@ -570,7 +567,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.15)
|
||||
rack (2.2.14)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -584,28 +581,23 @@ 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)
|
||||
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)
|
||||
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)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.5.1)
|
||||
railties (= 7.0.8.7)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -613,14 +605,13 @@ 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.1.5.1)
|
||||
actionpack (= 7.1.5.1)
|
||||
activesupport (= 7.1.5.1)
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
railties (7.0.8.7)
|
||||
actionpack (= 7.0.8.7)
|
||||
activesupport (= 7.0.8.7)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
zeitwerk (~> 2.6)
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rb-fsevent (0.11.2)
|
||||
@@ -632,7 +623,7 @@ GEM
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
regexp_parser (2.10.0)
|
||||
regexp_parser (2.8.0)
|
||||
reline (0.3.6)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
@@ -652,7 +643,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.4.1)
|
||||
rexml (3.3.9)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
@@ -672,36 +663,30 @@ GEM
|
||||
rspec-support (3.13.1)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.75.6)
|
||||
rubocop (1.50.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
parser (>= 3.2.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
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)
|
||||
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)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
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)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-rspec (2.21.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-capybara (~> 2.17)
|
||||
ruby-openai (7.3.1)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
@@ -831,10 +816,8 @@ GEM
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
@@ -862,9 +845,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.1)
|
||||
websocket-driver (0.7.7)
|
||||
base64
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wisper (2.0.0)
|
||||
@@ -970,7 +951,7 @@ DEPENDENCIES
|
||||
rack-cors (= 2.0.0)
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails (~> 7.0.8.4)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
@@ -979,7 +960,6 @@ DEPENDENCIES
|
||||
rspec-rails (>= 6.1.5)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-factory_bot
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
@@ -1017,7 +997,7 @@ DEPENDENCIES
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.4p34
|
||||
ruby 3.3.3p89
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.16
|
||||
|
||||
@@ -59,13 +59,11 @@ class ContactInboxBuilder
|
||||
end
|
||||
|
||||
def create_contact_inbox
|
||||
attrs = {
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
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,13 +152,11 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
end
|
||||
|
||||
def message_already_exists?
|
||||
find_message_by_source_id(@messaging[:message][:mid]).present?
|
||||
end
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
|
||||
def find_message_by_source_id(source_id)
|
||||
return unless source_id
|
||||
|
||||
@message = Message.find_by(source_id: source_id)
|
||||
cw_message.present?
|
||||
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, :category_id, :author_id, :associated_article_id, :status,
|
||||
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
|
||||
:locale, meta: [:title,
|
||||
:description,
|
||||
{ tags: [] }]
|
||||
|
||||
@@ -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 and token to that chain as well
|
||||
before_action(only: [:login, :token]) { set_resource }
|
||||
before_action(only: [:login, :token]) { validate_platform_app_permissible }
|
||||
# we want to add login to that chain as well
|
||||
before_action(only: [:login]) { set_resource }
|
||||
before_action(only: [:login]) { validate_platform_app_permissible }
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -18,8 +18,6 @@ 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)
|
||||
|
||||
|
||||
@@ -134,6 +134,10 @@ class ConversationApi extends ApiClient {
|
||||
return axios.get(`${this.url}/${conversationId}/attachments`);
|
||||
}
|
||||
|
||||
requestCopilot(conversationId, body) {
|
||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
||||
}
|
||||
|
||||
getInboxAssistant(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
.tabs--container--with-border {
|
||||
@apply border-b border-b-n-weak;
|
||||
@apply border-b border-n-weak;
|
||||
}
|
||||
|
||||
.tabs--container--compact.tab--chat-type {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch, computed } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
@@ -7,10 +8,8 @@ import CopilotInput from './CopilotInput.vue';
|
||||
import CopilotLoader from './CopilotLoader.vue';
|
||||
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||
import CopilotHeader from './CopilotHeader.vue';
|
||||
import CopilotEmptyState from './CopilotEmptyState.vue';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
supportAgent: {
|
||||
@@ -27,7 +26,7 @@ const props = defineProps({
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
assistants: {
|
||||
type: Array,
|
||||
@@ -39,13 +38,22 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant', 'close']);
|
||||
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
||||
|
||||
const sendMessage = message => {
|
||||
emit('sendMessage', message);
|
||||
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
|
||||
};
|
||||
|
||||
const useSuggestion = opt => {
|
||||
emit('sendMessage', t(opt.prompt));
|
||||
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
emit('reset');
|
||||
};
|
||||
@@ -59,28 +67,20 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const groupedMessages = computed(() => {
|
||||
const result = [];
|
||||
let thinkingGroup = [];
|
||||
|
||||
props.messages.forEach(message => {
|
||||
if (message.role === 'assistant_thinking') {
|
||||
thinkingGroup.push(message);
|
||||
} else {
|
||||
if (thinkingGroup.length > 0) {
|
||||
result.push({ type: 'thinking_group', messages: thinkingGroup });
|
||||
thinkingGroup = [];
|
||||
}
|
||||
result.push({ type: 'message', message });
|
||||
}
|
||||
});
|
||||
|
||||
if (thinkingGroup.length > 0) {
|
||||
result.push({ type: 'thinking_group', messages: thinkingGroup });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
const promptOptions = [
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||
},
|
||||
];
|
||||
|
||||
watch(
|
||||
[() => props.messages, () => props.isCaptainTyping],
|
||||
@@ -93,62 +93,60 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
||||
<CopilotHeader
|
||||
:has-messages="messages.length > 0"
|
||||
@reset="handleReset"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
<div
|
||||
ref="chatContainer"
|
||||
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
|
||||
>
|
||||
<div v-if="messages.length" class="space-y-6 flex-1 flex flex-col w-full">
|
||||
<template
|
||||
v-for="item in groupedMessages"
|
||||
:key="item.type === 'message' ? item.message.id : 'thinking-group'"
|
||||
>
|
||||
<template v-if="item.type === 'message'">
|
||||
<CopilotAgentMessage
|
||||
v-if="item.message.role === 'user'"
|
||||
:support-agent="supportAgent"
|
||||
:message="item.message"
|
||||
/>
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="
|
||||
item.message.role === 'assistant' ||
|
||||
item.message.role === 'system'
|
||||
"
|
||||
:message="item.message"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
/>
|
||||
</template>
|
||||
<CopilotThinkingGroup
|
||||
v-else
|
||||
:messages="item.messages"
|
||||
:has-assistant-message-after="
|
||||
groupedMessages[groupedMessages.indexOf(item) + 1]?.message
|
||||
?.role === 'assistant'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<CopilotAgentMessage
|
||||
v-if="message.role === 'user'"
|
||||
:support-agent="supportAgent"
|
||||
:message="message"
|
||||
/>
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
|
||||
:message="message"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CopilotLoader v-if="isCaptainTyping" />
|
||||
<CopilotLoader v-if="isCaptainTyping" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!messages.length"
|
||||
class="h-full w-full flex items-center justify-center"
|
||||
>
|
||||
<div class="h-fit px-3 py-3 space-y-1">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
||||
</span>
|
||||
<button
|
||||
v-for="prompt in promptOptions"
|
||||
:key="prompt.label"
|
||||
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
|
||||
@click="() => useSuggestion(prompt)"
|
||||
>
|
||||
<span>{{ t(prompt.label) }}</span>
|
||||
<Icon icon="i-lucide-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
<CopilotEmptyState
|
||||
v-if="!messages.length"
|
||||
@use-suggestion="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-px mb-2">
|
||||
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
||||
<ToggleCopilotAssistant
|
||||
v-if="assistants.length > 1"
|
||||
v-if="assistants.length"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="$event => emit('setAssistant', $event)"
|
||||
/>
|
||||
<div v-else />
|
||||
<button
|
||||
v-if="messages.length"
|
||||
class="text-xs flex items-center gap-1 hover:underline"
|
||||
@click="handleReset"
|
||||
>
|
||||
<i class="i-lucide-refresh-ccw" />
|
||||
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<script setup>
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
supportAgent: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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: {
|
||||
@@ -17,7 +18,7 @@ const props = defineProps({
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,25 +46,33 @@ const useCopilotResponse = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 gap-2">
|
||||
<Avatar
|
||||
name="Captain Copilot"
|
||||
icon-name="i-woot-captain"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
|
||||
const emit = defineEmits(['useSuggestion']);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const routePromptMap = {
|
||||
conversations: [
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
|
||||
},
|
||||
],
|
||||
dashboard: [
|
||||
{
|
||||
label: 'High priority conversations',
|
||||
prompt: 'Get me a summary of all high priority open conversations',
|
||||
},
|
||||
{
|
||||
label: 'List contacts',
|
||||
prompt: 'Show me the list of contacts',
|
||||
},
|
||||
{
|
||||
label: 'Summarize articles',
|
||||
prompt: 'List articles that are in draft',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getCurrentRoute = () => {
|
||||
const path = route.path;
|
||||
if (path.includes('/conversations')) return 'conversations';
|
||||
if (path.includes('/dashboard')) return 'dashboard';
|
||||
if (path.includes('/contacts')) return 'contacts';
|
||||
if (path.includes('/articles')) return 'articles';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const promptOptions = computed(() => {
|
||||
const currentRoute = getCurrentRoute();
|
||||
return routePromptMap[currentRoute] || routePromptMap.conversations;
|
||||
});
|
||||
|
||||
const handleSuggestion = opt => {
|
||||
emit('useSuggestion', t(opt.prompt));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col gap-6 px-2">
|
||||
<div class="flex flex-col space-y-4 py-4">
|
||||
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12 leading-8">
|
||||
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11 leading-6">
|
||||
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full space-y-2">
|
||||
<span class="text-xs text-n-slate-10 block">
|
||||
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
|
||||
</span>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
v-for="prompt in promptOptions"
|
||||
:key="prompt.label"
|
||||
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
|
||||
@click="handleSuggestion(prompt)"
|
||||
>
|
||||
<span>{{ t(prompt.label) }}</span>
|
||||
<Icon icon="i-lucide-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,7 +11,7 @@ defineEmits(['reset', 'close']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
|
||||
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">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const getUIState = useMapGetter('uiState/getUIState');
|
||||
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
|
||||
|
||||
const isConversationRoute = computed(() => {
|
||||
const CONVERSATION_ROUTES = [
|
||||
'inbox_conversation',
|
||||
'conversation_through_inbox',
|
||||
'conversations_through_label',
|
||||
'team_conversations_through_label',
|
||||
'conversations_through_folders',
|
||||
'conversation_through_mentions',
|
||||
'conversation_through_unattended',
|
||||
'conversation_through_participating',
|
||||
];
|
||||
return CONVERSATION_ROUTES.includes(route.name);
|
||||
});
|
||||
|
||||
const toggleSidebar = () => {
|
||||
store.dispatch('uiState/toggle', 'isCopilotSidebarOpen');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed bottom-4 right-4 z-50">
|
||||
<div
|
||||
v-if="!isSidebarOpen && !isConversationRoute"
|
||||
class="rounded-full bg-n-solid-2 p-2 hover:bg-n-solid-3"
|
||||
>
|
||||
<Button
|
||||
icon="i-woot-captain"
|
||||
class="!rounded-full !bg-n-alpha-2 text-n-slate-12 text-xl"
|
||||
lg
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||
<ul class="flex flex-col gap-2 m-0 list-none">
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
|
||||
@@ -824,7 +824,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[21rem] 2xl:w-[26rem]',
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -80,14 +80,16 @@ const toggleConversationLayout = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 h-12 pt-3 pb-3"
|
||||
class="flex items-center justify-between gap-2 px-4"
|
||||
:class="{
|
||||
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
'pt-3 pb-2': showV4View,
|
||||
'mb-2 pb-0': !showV4View,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-center min-w-0">
|
||||
<h1
|
||||
class="text-base font-medium truncate text-n-slate-12"
|
||||
class="text-lg font-medium truncate text-n-slate-12"
|
||||
:title="pageTitle"
|
||||
>
|
||||
{{ pageTitle }}
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, watchEffect } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const getUIState = useMapGetter('uiState/getUIState');
|
||||
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
|
||||
|
||||
const messages = ref([]);
|
||||
const isCaptainTyping = ref(false);
|
||||
const selectedAssistantId = ref(null);
|
||||
const copilotConnector = ref(null);
|
||||
|
||||
const activeAssistant = computed(() => {
|
||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||
@@ -52,12 +59,7 @@ const handleReset = () => {
|
||||
messages.value = [];
|
||||
};
|
||||
|
||||
const sendMessage = message => {
|
||||
// Ensure WebSocket is connected before sending
|
||||
if (!copilotConnector.value || !isSidebarOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sendMessage = async message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
@@ -66,71 +68,55 @@ const sendMessage = message => {
|
||||
});
|
||||
isCaptainTyping.value = true;
|
||||
|
||||
copilotConnector.value.sendMessage({
|
||||
assistantId: activeAssistant.value.id,
|
||||
conversationId: currentChat.value?.id,
|
||||
previousHistory: messages.value
|
||||
.filter(m => m.role !== 'assistant_thinking')
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
.slice(0, -1),
|
||||
message,
|
||||
});
|
||||
try {
|
||||
const { data } = await ConversationAPI.requestCopilot(
|
||||
props.conversationId,
|
||||
{
|
||||
previous_history: messages.value
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
.slice(0, -1),
|
||||
message,
|
||||
assistant_id: selectedAssistantId.value,
|
||||
}
|
||||
);
|
||||
messages.value.push({
|
||||
id: new Date().getTime(),
|
||||
role: 'assistant',
|
||||
content: data.message,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line
|
||||
console.log(error);
|
||||
} finally {
|
||||
isCaptainTyping.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
});
|
||||
|
||||
// const onCopilotResponse = data => {
|
||||
// if (data.type === 'final_response') {
|
||||
// messages.value.push({
|
||||
// id: new Date().getTime(),
|
||||
// role: 'assistant',
|
||||
// content: data.response.response,
|
||||
// });
|
||||
// isCaptainTyping.value = false;
|
||||
// } else if (data.type === 'ui_event') {
|
||||
// if (data.event === 'ui:linear_ticket_create') {
|
||||
// emitter.emit('ui:linear_ticket_create');
|
||||
// setTimeout(() => {
|
||||
// emitter.emit('ui:linear_ticket_create_data', data.response);
|
||||
// }, 100);
|
||||
// }
|
||||
// } else {
|
||||
// messages.value.push({
|
||||
// id: new Date().getTime(),
|
||||
// role: 'assistant_thinking',
|
||||
// content: data.response.response,
|
||||
// reasoning: data.response.reasoning,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
const handleClose = () => {
|
||||
store.dispatch('uiState/set', { isCopilotSidebarOpen: false });
|
||||
};
|
||||
watchEffect(() => {
|
||||
if (props.conversationId) {
|
||||
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
||||
selectedAssistantId.value = activeAssistant.value?.id;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
class="border-l border-n-weak w-[20rem] min-w-[20rem]"
|
||||
>
|
||||
<Copilot
|
||||
:messages="messages"
|
||||
:support-agent="currentUser"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="setAssistant"
|
||||
@send-message="sendMessage"
|
||||
@reset="handleReset"
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="hidden" />
|
||||
<Copilot
|
||||
:messages="messages"
|
||||
:support-agent="currentUser"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="setAssistant"
|
||||
@send-message="sendMessage"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-3 -mt-1 py-0 tab--chat-type"
|
||||
class="w-full px-4 py-0 tab--chat-type"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
|
||||
@@ -83,7 +83,7 @@ export default {
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="hasOpenedAtleastOnce" class="flex-1">
|
||||
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
|
||||
<div
|
||||
v-for="(configItem, index) in config"
|
||||
:key="index"
|
||||
@@ -115,7 +115,6 @@ export default {
|
||||
.dashboard-app--list iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dashboard-app_loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,9 +4,11 @@ import ConversationHeader from './ConversationHeader.vue';
|
||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||
import EmptyState from './EmptyState/EmptyState.vue';
|
||||
import MessagesView from './MessagesView.vue';
|
||||
import ConversationSidebar from './ConversationSidebar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationSidebar,
|
||||
ConversationHeader,
|
||||
DashboardAppFrame,
|
||||
EmptyState,
|
||||
@@ -23,11 +25,16 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
data() {
|
||||
return { activeIndex: 0 };
|
||||
},
|
||||
@@ -50,6 +57,9 @@ export default {
|
||||
})),
|
||||
];
|
||||
},
|
||||
showContactPanel() {
|
||||
return this.isContactPanelOpen && this.currentChat.id;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'currentChat.inbox_id': {
|
||||
@@ -76,6 +86,9 @@ export default {
|
||||
}
|
||||
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.$emit('contactPanelToggle');
|
||||
},
|
||||
onDashboardAppTabChange(index) {
|
||||
this.activeIndex = index;
|
||||
},
|
||||
@@ -85,7 +98,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation-details-wrap bg-n-background relative"
|
||||
class="conversation-details-wrap bg-n-background"
|
||||
:class="{
|
||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||
}"
|
||||
@@ -94,12 +107,14 @@ export default {
|
||||
v-if="currentChat.id"
|
||||
:chat="currentChat"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:show-back-button="isOnExpandedLayout && !isInboxView"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
:index="activeIndex"
|
||||
class="-mt-px border-t border-t-n-background bg-n-background dashboard-app--tabs"
|
||||
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
|
||||
@change="onDashboardAppTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
@@ -110,17 +125,23 @@ export default {
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div v-show="!activeIndex" class="flex-1 h-full min-h-0 m-0">
|
||||
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
|
||||
<MessagesView
|
||||
v-if="currentChat.id"
|
||||
:inbox-id="inboxId"
|
||||
:is-inbox-view="isInboxView"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!currentChat.id && !isInboxView"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
/>
|
||||
<slot />
|
||||
<ConversationSidebar
|
||||
v-if="showContactPanel"
|
||||
:current-chat="currentChat"
|
||||
@toggle-contact-panel="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<DashboardAppFrame
|
||||
v-for="(dashboardApp, index) in dashboardApps"
|
||||
|
||||
@@ -243,7 +243,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||
isActiveChat,
|
||||
@@ -278,7 +278,7 @@ export default {
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="32px"
|
||||
size="40px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
@@ -12,6 +13,8 @@ import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BackButton,
|
||||
@@ -20,6 +23,7 @@ export default {
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
NextButton,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -27,6 +31,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showBackButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -36,6 +44,15 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
setup(props, { emit }) {
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: () => emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
@@ -82,7 +99,13 @@ export default {
|
||||
}
|
||||
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
|
||||
},
|
||||
|
||||
contactPanelToggleText() {
|
||||
return `${
|
||||
this.isContactPanelOpen
|
||||
? this.$t('CONVERSATION.HEADER.CLOSE')
|
||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
||||
},
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
@@ -110,7 +133,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-between px-3 border-b bg-n-background border-n-weak md:flex-row h-12"
|
||||
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
|
||||
@@ -127,18 +150,20 @@ export default {
|
||||
:badge="inboxBadge"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="32px"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
|
||||
>
|
||||
<div class="flex items-center max-w-full gap-1 p-0 m-0 w-fit">
|
||||
<span
|
||||
class="text-sm font-medium truncate leading-tight text-n-slate-12"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
|
||||
>
|
||||
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
|
||||
<span
|
||||
class="text-base font-medium truncate leading-tight text-n-slate-12"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</span>
|
||||
</NextButton>
|
||||
<fluent-icon
|
||||
v-if="!isHMACVerified"
|
||||
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||
@@ -155,11 +180,19 @@ export default {
|
||||
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
|
||||
{{ snoozedDisplayText }}
|
||||
</span>
|
||||
<NextButton
|
||||
link
|
||||
xs
|
||||
blue
|
||||
:label="contactPanelToggleText"
|
||||
@click="$emit('contactPanelToggle')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
|
||||
<Linear
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
|
||||
const props = defineProps({
|
||||
currentChat: {
|
||||
@@ -11,48 +14,69 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const getUIState = useMapGetter('uiState/getUIState');
|
||||
const isCopilotSidebarOpen = computed(() =>
|
||||
getUIState.value('isCopilotSidebarOpen')
|
||||
);
|
||||
const isConversationSidebarOpen = computed(() =>
|
||||
getUIState.value('isConversationSidebarOpen')
|
||||
);
|
||||
const emit = defineEmits(['toggleContactPanel']);
|
||||
|
||||
const showConversationSidebar = computed(
|
||||
() =>
|
||||
props.currentChat.id &&
|
||||
!isCopilotSidebarOpen.value &&
|
||||
isConversationSidebarOpen.value
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useStore();
|
||||
const closeConversationPanel = () => {
|
||||
store.dispatch('uiState/set', { isConversationSidebarOpen: false });
|
||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'CONTACT', value: 'contact' },
|
||||
{ key: 'COPILOT', value: 'copilot' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
|
||||
value: tab.value,
|
||||
}));
|
||||
});
|
||||
const activeTab = ref(0);
|
||||
const toggleContactPanel = () => {
|
||||
emit('toggleContactPanel');
|
||||
};
|
||||
|
||||
const handleTabChange = selectedTab => {
|
||||
activeTab.value = tabs.value.findIndex(
|
||||
tabItem => tabItem.value === selectedTab.value
|
||||
);
|
||||
};
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showCopilotTab = computed(() =>
|
||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showConversationSidebar"
|
||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[20rem] min-w-[20rem] flex flex-col"
|
||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
||||
>
|
||||
<div class="flex flex-1 flex-col overflow-auto">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-4 py-2 border-b border-n-weak h-12"
|
||||
>
|
||||
<span class="font-medium text-sm text-n-slate-12">
|
||||
{{ $t('CONVERSATION.SIDEBAR.ACTIONS') }}
|
||||
</span>
|
||||
<div class="flex items-center">
|
||||
<Button icon="i-lucide-x" ghost sm @click="closeConversationPanel" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showCopilotTab" class="p-2">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTab"
|
||||
class="w-full [&>button]:w-full"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<ContactPanel
|
||||
:conversation-id="props.currentChat.id"
|
||||
:inbox-id="props.currentChat.inbox_id"
|
||||
v-if="!activeTab"
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
:on-toggle="toggleContactPanel"
|
||||
/>
|
||||
<CopilotContainer
|
||||
v-else-if="activeTab === 1 && showCopilotTab"
|
||||
:key="currentChat.id"
|
||||
:conversation-inbox-type="channelType"
|
||||
:conversation-id="currentChat.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="hidden" />
|
||||
</template>
|
||||
|
||||
@@ -38,6 +38,8 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
@@ -45,8 +47,20 @@ export default {
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
NextButton,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isInboxView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['contactPanelToggle'],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const { isEnterprise } = useConfig();
|
||||
@@ -185,6 +199,12 @@ export default {
|
||||
isATweet() {
|
||||
return this.conversationType === 'tweet';
|
||||
},
|
||||
isRightOrLeftIcon() {
|
||||
if (this.isContactPanelOpen) {
|
||||
return 'arrow-chevron-right';
|
||||
}
|
||||
return 'arrow-chevron-left';
|
||||
},
|
||||
getLastSeenAt() {
|
||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt;
|
||||
@@ -420,6 +440,9 @@ export default {
|
||||
relevantMessages
|
||||
);
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.$emit('contactPanelToggle');
|
||||
},
|
||||
setScrollParams() {
|
||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||
@@ -503,7 +526,19 @@ export default {
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<NextButton
|
||||
faded
|
||||
xs
|
||||
slate
|
||||
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
|
||||
:icon="
|
||||
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
|
||||
"
|
||||
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
|
||||
@click="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<NextMessageList
|
||||
v-if="showNextBubbles"
|
||||
class="conversation-panel"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import SearchableDropdown from './SearchableDropdown.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -205,12 +204,6 @@ const createIssue = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEmitter('ui:linear_ticket_create_data', data => {
|
||||
formState.title = data.title;
|
||||
formState.description = data.description;
|
||||
formState.priority = data.priority;
|
||||
});
|
||||
|
||||
onMounted(getTeams);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTrack } from 'dashboard/composables';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -95,10 +94,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
useEmitter('ui:linear_ticket_create', () => {
|
||||
shouldShowPopup.value = true;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadLinkedIssue();
|
||||
});
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"ACTIONS": "Actions"
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
@@ -379,6 +380,9 @@
|
||||
"TWO": "{user} and {secondUser} are typing",
|
||||
"MULTIPLE": "{user} and {count} others are typing"
|
||||
},
|
||||
"COPILOT": {
|
||||
"TRY_THESE_PROMPTS": "Try these prompts"
|
||||
},
|
||||
"GALLERY_VIEW": {
|
||||
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
|
||||
}
|
||||
|
||||
@@ -328,17 +328,14 @@
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"COPILOT": {
|
||||
"TITLE": "Copilot",
|
||||
"TRY_THESE_PROMPTS": "Try these prompts",
|
||||
"PANEL_TITLE": "Get started with Copilot",
|
||||
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilot’s here to speed things up.",
|
||||
"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",
|
||||
"SELECT_ASSISTANT": "Select Assistant",
|
||||
"SHOW_STEPS": "Show steps",
|
||||
"SELECT_ASSISTANT": "Select Assistant",
|
||||
"PROMPTS": {
|
||||
"SUMMARIZE": {
|
||||
"LABEL": "Summarize this conversation",
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
<script setup>
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
<script>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||
@@ -18,8 +9,6 @@ import AccountSelector from 'dashboard/components/layout/sidebarComponents/Accou
|
||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
|
||||
import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
|
||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
@@ -27,160 +16,178 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const CommandBar = defineAsyncComponent(
|
||||
() => import('./commands/commandbar.vue')
|
||||
);
|
||||
|
||||
const Sidebar = defineAsyncComponent(
|
||||
() => import('../../components/layout/Sidebar.vue')
|
||||
);
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const route = useRoute();
|
||||
export default {
|
||||
components: {
|
||||
NextSidebar,
|
||||
Sidebar,
|
||||
CommandBar,
|
||||
WootKeyShortcutModal,
|
||||
AddAccountModal,
|
||||
AccountSelector,
|
||||
AddLabelModal,
|
||||
NotificationPanel,
|
||||
UpgradePage,
|
||||
},
|
||||
setup() {
|
||||
const upgradePageRef = ref(null);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const upgradePageRef = ref(null);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { currentAccount } = useAccount();
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAccountModal: false,
|
||||
showCreateAccountModal: false,
|
||||
showAddLabelModal: false,
|
||||
showShortcutModal: false,
|
||||
isNotificationPanel: false,
|
||||
displayLayoutType: '',
|
||||
hasBanner: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentRoute() {
|
||||
return ' ';
|
||||
},
|
||||
showUpgradePage() {
|
||||
return this.upgradePageRef?.shouldShowUpgradePage;
|
||||
},
|
||||
bypassUpgradePage() {
|
||||
return [
|
||||
'billing_settings_index',
|
||||
'settings_inbox_list',
|
||||
'agent_list',
|
||||
].includes(this.$route.name);
|
||||
},
|
||||
isSidebarOpen() {
|
||||
const { show_secondary_sidebar: showSecondarySidebar } = this.uiSettings;
|
||||
return showSecondarySidebar;
|
||||
},
|
||||
previouslyUsedDisplayType() {
|
||||
const {
|
||||
previously_used_conversation_display_type: conversationDisplayType,
|
||||
} = this.uiSettings;
|
||||
return conversationDisplayType;
|
||||
},
|
||||
previouslyUsedSidebarView() {
|
||||
const { previously_used_sidebar_view: showSecondarySidebar } =
|
||||
this.uiSettings;
|
||||
return showSecondarySidebar;
|
||||
},
|
||||
showNextSidebar() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
displayLayoutType() {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
this.updateUISettings({
|
||||
conversation_display_type:
|
||||
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: this.previouslyUsedDisplayType,
|
||||
show_secondary_sidebar:
|
||||
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
|
||||
? false
|
||||
: this.previouslyUsedSidebarView,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.handleResize();
|
||||
this.$nextTick(this.checkBanner);
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
window.addEventListener('resize', this.checkBanner);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('resize', this.checkBanner);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
},
|
||||
|
||||
const showAccountModal = ref(false);
|
||||
const showCreateAccountModal = ref(false);
|
||||
const showAddLabelModal = ref(false);
|
||||
const showShortcutModal = ref(false);
|
||||
const isNotificationPanel = ref(false);
|
||||
const displayLayoutType = ref('');
|
||||
const hasBanner = ref('');
|
||||
methods: {
|
||||
checkBanner() {
|
||||
this.hasBanner =
|
||||
document.getElementsByClassName('woot-banner').length > 0;
|
||||
},
|
||||
handleResize() {
|
||||
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
|
||||
let throttled = false;
|
||||
const delay = 150;
|
||||
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
if (throttled) {
|
||||
return;
|
||||
}
|
||||
throttled = true;
|
||||
|
||||
const currentRoute = computed(() => ' ');
|
||||
|
||||
const showUpgradePage = computed(
|
||||
() => upgradePageRef.value?.shouldShowUpgradePage
|
||||
);
|
||||
|
||||
const bypassUpgradePage = computed(() =>
|
||||
['billing_settings_index', 'settings_inbox_list', 'agent_list'].includes(
|
||||
route.name
|
||||
)
|
||||
);
|
||||
|
||||
const isSidebarOpen = computed(() => {
|
||||
const { show_secondary_sidebar: showSecondarySidebar } = uiSettings;
|
||||
return showSecondarySidebar;
|
||||
});
|
||||
|
||||
const previouslyUsedDisplayType = computed(() => {
|
||||
const { previously_used_conversation_display_type: conversationDisplayType } =
|
||||
uiSettings;
|
||||
return conversationDisplayType;
|
||||
});
|
||||
|
||||
const previouslyUsedSidebarView = computed(() => {
|
||||
const { previously_used_sidebar_view: showSecondarySidebar } = uiSettings;
|
||||
return showSecondarySidebar;
|
||||
});
|
||||
|
||||
const showNextSidebar = computed(() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccount.value?.id,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
)
|
||||
);
|
||||
|
||||
const checkBanner = () => {
|
||||
hasBanner.value = document.getElementsByClassName('woot-banner').length > 0;
|
||||
setTimeout(() => {
|
||||
throttled = false;
|
||||
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
|
||||
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
|
||||
} else {
|
||||
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
toggleSidebar() {
|
||||
this.updateUISettings({
|
||||
show_secondary_sidebar: !this.isSidebarOpen,
|
||||
previously_used_sidebar_view: !this.isSidebarOpen,
|
||||
});
|
||||
},
|
||||
openCreateAccountModal() {
|
||||
this.showAccountModal = false;
|
||||
this.showCreateAccountModal = true;
|
||||
},
|
||||
closeCreateAccountModal() {
|
||||
this.showCreateAccountModal = false;
|
||||
},
|
||||
toggleAccountModal() {
|
||||
this.showAccountModal = !this.showAccountModal;
|
||||
},
|
||||
toggleKeyShortcutModal() {
|
||||
this.showShortcutModal = true;
|
||||
},
|
||||
closeKeyShortcutModal() {
|
||||
this.showShortcutModal = false;
|
||||
},
|
||||
showAddLabelPopup() {
|
||||
this.showAddLabelModal = true;
|
||||
},
|
||||
hideAddLabelPopup() {
|
||||
this.showAddLabelModal = false;
|
||||
},
|
||||
openNotificationPanel() {
|
||||
this.isNotificationPanel = true;
|
||||
},
|
||||
closeNotificationPanel() {
|
||||
this.isNotificationPanel = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
|
||||
let throttled = false;
|
||||
const delay = 150;
|
||||
|
||||
if (throttled) return;
|
||||
throttled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
throttled = false;
|
||||
displayLayoutType.value =
|
||||
window.innerWidth <= SMALL_SCREEN_BREAKPOINT
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: LAYOUT_TYPES.CONDENSED;
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
updateUISettings({
|
||||
show_secondary_sidebar: !isSidebarOpen.value,
|
||||
previously_used_sidebar_view: !isSidebarOpen.value,
|
||||
});
|
||||
};
|
||||
|
||||
const openCreateAccountModal = () => {
|
||||
showAccountModal.value = false;
|
||||
showCreateAccountModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateAccountModal = () => {
|
||||
showCreateAccountModal.value = false;
|
||||
};
|
||||
|
||||
const toggleAccountModal = () => {
|
||||
showAccountModal.value = !showAccountModal.value;
|
||||
};
|
||||
|
||||
const toggleKeyShortcutModal = () => {
|
||||
showShortcutModal.value = true;
|
||||
};
|
||||
|
||||
const closeKeyShortcutModal = () => {
|
||||
showShortcutModal.value = false;
|
||||
};
|
||||
|
||||
const showAddLabelPopup = () => {
|
||||
showAddLabelModal.value = true;
|
||||
};
|
||||
|
||||
const hideAddLabelPopup = () => {
|
||||
showAddLabelModal.value = false;
|
||||
};
|
||||
|
||||
const openNotificationPanel = () => {
|
||||
isNotificationPanel.value = true;
|
||||
};
|
||||
|
||||
const closeNotificationPanel = () => {
|
||||
isNotificationPanel.value = false;
|
||||
};
|
||||
|
||||
watch(displayLayoutType, () => {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
updateUISettings({
|
||||
conversation_display_type:
|
||||
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: previouslyUsedDisplayType.value,
|
||||
show_secondary_sidebar:
|
||||
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
|
||||
? false
|
||||
: previouslyUsedSidebarView.value,
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
nextTick(checkBanner);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('resize', checkBanner);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('resize', checkBanner);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -211,8 +218,6 @@ onUnmounted(() => {
|
||||
/>
|
||||
<template v-if="!showUpgradePage">
|
||||
<router-view />
|
||||
<CopilotLauncher />
|
||||
<CopilotContainer />
|
||||
<CommandBar />
|
||||
<NotificationPanel
|
||||
v-if="isNotificationPanel"
|
||||
|
||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
onToggle: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -85,6 +89,8 @@ watch(conversationId, (newConversationId, prevConversationId) => {
|
||||
|
||||
watch(contactId, getContactDetails);
|
||||
|
||||
const onPanelToggle = props.onToggle;
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragging.value = false;
|
||||
updateUISettings({
|
||||
@@ -101,7 +107,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<ContactInfo :contact="contact" :channel-type="channelType" />
|
||||
<ContactInfo
|
||||
:contact="contact"
|
||||
:channel-type="channelType"
|
||||
@toggle-panel="onPanelToggle"
|
||||
/>
|
||||
<div class="list-group pb-8">
|
||||
<Draggable
|
||||
:list="conversationSidebarItems"
|
||||
|
||||
@@ -10,17 +10,13 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
|
||||
import PanelButtons from './PanelButtons.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatList,
|
||||
ConversationBox,
|
||||
ConversationSidebar,
|
||||
PopOverSearch,
|
||||
CmdBarConversationSnooze,
|
||||
PanelButtons,
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
|
||||
@@ -91,6 +87,14 @@ export default {
|
||||
this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
||||
this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showPopOverSearch() {
|
||||
return !this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
@@ -185,7 +189,11 @@ export default {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
}
|
||||
},
|
||||
|
||||
onToggleContactPanel() {
|
||||
this.updateUISettings({
|
||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
||||
});
|
||||
},
|
||||
onSearch() {
|
||||
this.showSearchModal = true;
|
||||
},
|
||||
@@ -217,11 +225,10 @@ export default {
|
||||
<ConversationBox
|
||||
v-if="showMessageView"
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
>
|
||||
<PanelButtons v-if="currentChat.id" />
|
||||
</ConversationBox>
|
||||
<ConversationSidebar :current-chat="currentChat" />
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<CmdBarConversationSnooze />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
store.dispatch('uiState/set', {
|
||||
isConversationSidebarOpen: true,
|
||||
isCopilotSidebarOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopilotSidebarToggle = () => {
|
||||
store.dispatch('uiState/set', {
|
||||
isCopilotSidebarOpen: true,
|
||||
isConversationSidebarOpen: false,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-center items-center absolute top-24 right-1 bg-n-alpha-2 border border-n-weak rounded-lg bg-n-background gap-2 py-0.5 px-0.5"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!text-sm"
|
||||
icon="i-ph-user-bold"
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
class="!text-sm !text-n-iris-11"
|
||||
sm
|
||||
icon="i-woot-captain"
|
||||
@click="handleCopilotSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -51,7 +51,6 @@ import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
import uiState from './modules/uiState';
|
||||
const plugins = [];
|
||||
|
||||
export default createStore({
|
||||
@@ -107,7 +106,6 @@ export default createStore({
|
||||
captainResponses,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
uiState,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
const initialState = {
|
||||
isCopilotSidebarOpen: false,
|
||||
isConversationSidebarOpen: true,
|
||||
};
|
||||
|
||||
const types = {
|
||||
SET_UI_STATE: 'SET_UI_STATE',
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
[types.SET_UI_STATE](state, { key, value }) {
|
||||
state[key] = value;
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getUIState: state => key => state[key],
|
||||
};
|
||||
|
||||
const actions = {
|
||||
toggle({ commit, state }, key) {
|
||||
commit(types.SET_UI_STATE, { key, value: !state[key] });
|
||||
},
|
||||
set({ commit }, payload) {
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
commit(types.SET_UI_STATE, { key, value });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: initialState,
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
};
|
||||
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
end
|
||||
|
||||
def in_reply_to_matches?(in_reply_to)
|
||||
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
|
||||
Array.wrap(in_reply_to).any? { _1.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, prefix: true
|
||||
enum :status, { active: 0, suspended: 1 }
|
||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
||||
enum status: { active: 0, suspended: 1 }
|
||||
|
||||
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
|
||||
|
||||
|
||||
@@ -128,12 +128,6 @@ 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, prefix: true
|
||||
enum :template_type, { layout: 0, content: 1 }
|
||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
||||
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, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
|
||||
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
|
||||
|
||||
before_validation :set_lock
|
||||
validates :name, presence: true
|
||||
@@ -32,10 +32,6 @@ 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
|
||||
|
||||
|
||||
@@ -349,6 +349,7 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def execute_message_template_hooks
|
||||
Rails.logger.info("[Message][#{id}] Executing message template hooks")
|
||||
::MessageTemplates::HookExecutionService.new(message: self).perform
|
||||
end
|
||||
|
||||
|
||||
+2
-2
@@ -109,8 +109,8 @@ class User < ApplicationRecord
|
||||
self.email = email.try(:downcase)
|
||||
end
|
||||
|
||||
def send_devise_notification(notification, *)
|
||||
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
|
||||
def send_devise_notification(notification, *args)
|
||||
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later
|
||||
end
|
||||
|
||||
def set_password_and_uid
|
||||
|
||||
@@ -31,11 +31,7 @@ class Crm::Leadsquared::LeadFinderService
|
||||
def find_by_phone_number(contact)
|
||||
return if contact.phone_number.blank?
|
||||
|
||||
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
|
||||
|
||||
return if lead_data.blank? || lead_data['Mobile'].nil?
|
||||
|
||||
search_by_field(lead_data['Mobile'])
|
||||
search_by_field(contact.phone_number)
|
||||
end
|
||||
|
||||
def search_by_field(value)
|
||||
|
||||
@@ -20,29 +20,11 @@ class Crm::Leadsquared::Mappers::ContactMapper
|
||||
'FirstName' => contact.name.presence,
|
||||
'LastName' => contact.last_name.presence,
|
||||
'EmailAddress' => contact.email.presence,
|
||||
'Mobile' => formatted_phone_number,
|
||||
'Mobile' => contact.phone_number.presence,
|
||||
'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
|
||||
|
||||
@@ -2,9 +2,17 @@ class MessageTemplates::HookExecutionService
|
||||
pattr_initialize [:message!]
|
||||
|
||||
def perform
|
||||
return if conversation.campaign.present?
|
||||
return if conversation.last_incoming_message.blank?
|
||||
if conversation.campaign.present?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not triggering templates because conversation has a campaign" }
|
||||
return
|
||||
end
|
||||
|
||||
if conversation.last_incoming_message.blank?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not triggering templates because there is no incoming message" }
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "[OutOfOffice][#{conversation.id}] Triggering templates for conversation ##{conversation.id}"
|
||||
trigger_templates
|
||||
end
|
||||
|
||||
@@ -22,14 +30,41 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
if conversation.tweet?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because it's a tweet conversation" }
|
||||
return false
|
||||
end
|
||||
|
||||
# should not send for outbound messages
|
||||
return false unless message.incoming?
|
||||
unless message.incoming?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because the message is outgoing" }
|
||||
return false
|
||||
end
|
||||
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
# ensures better UX by not interrupting active conversations at the end of business hours
|
||||
return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
|
||||
if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago])
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because an agent responded in the last 5 minutes" }
|
||||
return false
|
||||
end
|
||||
|
||||
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
|
||||
can_send = inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
|
||||
|
||||
if can_send
|
||||
Rails.logger.info "[OutOfOffice][#{conversation.id}] Sending out-of-office message for conversation ##{conversation.id}"
|
||||
else
|
||||
reasons = []
|
||||
reasons << 'inbox not in out-of-office mode' unless inbox.out_of_office?
|
||||
reasons << 'conversation already has a template message today' unless conversation.messages.today.template.empty?
|
||||
reasons << 'inbox has no out-of-office message configured' unless inbox.out_of_office_message.present?
|
||||
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because: #{reasons.join(', ')}" }
|
||||
end
|
||||
|
||||
can_send
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[OutOfOffice][#{conversation.id}] Error triggering out of office message: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
false
|
||||
end
|
||||
|
||||
def first_message_from_contact?
|
||||
|
||||
@@ -12,6 +12,7 @@ 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?
|
||||
|
||||
@@ -6,6 +6,7 @@ class MessageTemplates::Template::OutOfOffice
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[OutOfOffice][#{conversation.id}] Error triggering out of office message: #{e.message}")
|
||||
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
@@ -137,19 +137,14 @@ class Twilio::IncomingMessageService
|
||||
end
|
||||
|
||||
def download_with_auth(media_url)
|
||||
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)
|
||||
Down.download(
|
||||
media_url,
|
||||
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
|
||||
)
|
||||
end
|
||||
|
||||
def handle_download_attachment_error(error, media_url)
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
|
||||
Down.download(media_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +1,3 @@
|
||||
# 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,10 +14,6 @@ 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,12 +11,7 @@ class Rack::Attack
|
||||
# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
# https://github.com/rack/rack-attack/issues/102
|
||||
# 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)
|
||||
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma)
|
||||
|
||||
class Request < ::Rack::Request
|
||||
# You many need to specify a method to fetch the correct remote IP address
|
||||
|
||||
+1
-1
@@ -127,6 +127,7 @@ Rails.application.routes.draw do
|
||||
post :unread
|
||||
post :custom_attributes
|
||||
get :attachments
|
||||
post :copilot
|
||||
get :inbox_assistant
|
||||
end
|
||||
end
|
||||
@@ -395,7 +396,6 @@ 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.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="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="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.4.4"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
|
||||
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"
|
||||
|
||||
@@ -21,10 +21,10 @@ MemoryHigh=1.4G
|
||||
MemorySwapMax=0
|
||||
OOMPolicy=stop
|
||||
|
||||
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="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="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.4.4"
|
||||
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
|
||||
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"
|
||||
|
||||
@@ -338,8 +338,8 @@ function setup_chatwoot() {
|
||||
sudo -i -u chatwoot << EOF
|
||||
rvm --version
|
||||
rvm autolibs disable
|
||||
rvm install "ruby-3.4.4"
|
||||
rvm use 3.4.4 --default
|
||||
rvm install "ruby-3.3.3"
|
||||
rvm use 3.3.3 --default
|
||||
|
||||
git clone https://github.com/chatwoot/chatwoot.git
|
||||
cd chatwoot
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
## Chatwoot Developer Documentation
|
||||
|
||||
Welcome to the official Chatwoot developer documentation. This guide contains everything you need to know about Chatwoot APIs and build custom flows on top of Chatwoot APIs.
|
||||
|
||||
### 👩💻 Development
|
||||
|
||||
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
|
||||
|
||||
```
|
||||
npm i -g mintlify
|
||||
```
|
||||
|
||||
Run the following command at the root of your documentation (where mint.json is)
|
||||
|
||||
```
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
### 😎 Publishing Changes
|
||||
|
||||
Changes will be deployed to production automatically after pushing to the default branch.
|
||||
|
||||
You can also preview changes using PRs, which generates a preview link of the docs.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"name": "Chatwoot Developer Docs",
|
||||
"description": "Official developer documentation for Chatwoot - the open-source customer support platform. Learn about our APIs, integrations, and development guidelines.",
|
||||
"logo": {
|
||||
"dark": "/logo/dark.png",
|
||||
"light": "/logo/light.png"
|
||||
},
|
||||
"favicon": "/favicon.png",
|
||||
"colors": {
|
||||
"primary": "#0069ED",
|
||||
"light": "#4D9CFF",
|
||||
"dark": "#0050B4"
|
||||
},
|
||||
"fonts": {
|
||||
"heading": {
|
||||
"family": "Haskoy",
|
||||
"weight": 500,
|
||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Medium.woff2",
|
||||
"format": "woff2"
|
||||
},
|
||||
"body": {
|
||||
"family": "Haskoy",
|
||||
"weight": 400,
|
||||
"source": "https://d1j5ayohogpcd2.cloudfront.net/fonts/haskoy/Haskoy-Regular.woff2",
|
||||
"format": "woff2"
|
||||
}
|
||||
},
|
||||
"theme": "maple",
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
"group": "API Reference",
|
||||
"pages": [
|
||||
"introduction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Application API",
|
||||
"description": "APIs for managing application aspects of Chatwoot",
|
||||
"includeTags": [
|
||||
"Agents",
|
||||
"Automation Rule",
|
||||
"Account AgentBots",
|
||||
"Canned Responses",
|
||||
"Contact Labels",
|
||||
"Contacts",
|
||||
"Conversation Assignments",
|
||||
"Conversation Labels",
|
||||
"Conversations",
|
||||
"Custom Attributes",
|
||||
"Custom Filters",
|
||||
"Help Center",
|
||||
"Inboxes",
|
||||
"Integrations",
|
||||
"Messages",
|
||||
"Profile",
|
||||
"Reports",
|
||||
"Teams",
|
||||
"Webhooks"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Platform API",
|
||||
"description": "APIs for managing platform aspects of Chatwoot",
|
||||
"includeTags": [
|
||||
"Accounts",
|
||||
"Account Users",
|
||||
"AgentBots",
|
||||
"Users"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/platform_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Client API",
|
||||
"description": "APIs for client applications",
|
||||
"includeTags": [
|
||||
"Contacts API",
|
||||
"Conversations API",
|
||||
"Messages API"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/client_swagger.json"
|
||||
},
|
||||
{
|
||||
"group": "Other APIs",
|
||||
"description": "Other Chatwoot APIs",
|
||||
"includeTags": [
|
||||
"CSAT Survey Page"
|
||||
],
|
||||
"openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/other_swagger.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"footerSocials": {
|
||||
"twitter": "https://twitter.com/chatwootapp",
|
||||
"github": "https://github.com/chatwoot",
|
||||
"linkedin": "https://www.linkedin.com/company/chatwoot"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: Introduction to Chatwoot APIs
|
||||
description: Learn how to use Chatwoot APIs to build integrations, customize chat experiences, and manage your installation.
|
||||
sidebarTitle: Introduction
|
||||
---
|
||||
|
||||
Welcome to the Chatwoot API documentation. Whether you're building custom workflows for your support team, integrating Chatwoot into your product, or managing users across installations, our APIs provide the flexibility and power to help you do more with Chatwoot.
|
||||
|
||||
Chatwoot provides three categories of APIs, each designed with a specific use case in mind:
|
||||
|
||||
- **Application APIs** – For account-level automation and agent-facing integrations.
|
||||
- **Client APIs** – For building custom chat interfaces for end-users
|
||||
- **Platform APIs** – For managing and administering installations at scale
|
||||
|
||||
---
|
||||
|
||||
## Application APIs
|
||||
|
||||
Application APIs are designed for interacting with a Chatwoot account from an agent/admin perspective. Use them to build internal tools, automate workflows, or perform bulk operations like data import/export.
|
||||
|
||||
- **Authentication**: Requires a user `access_token`, which can be generated from **Profile Settings** after logging into your Chatwoot account.
|
||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
||||
- **Example**: [Google Cloud Functions Demo](https://github.com/chatwoot/google-cloud-functions-demo)
|
||||
|
||||
---
|
||||
|
||||
## Client APIs
|
||||
|
||||
Client APIs are intended for building custom messaging experiences over Chatwoot. If you're not using the native website widget or want to embed chat in your mobile app, these APIs are the way to go.
|
||||
|
||||
- **Authentication**: Uses `inbox_identifier` (from **Settings → Configuration** in API inboxes) and `contact_identifier` (returned when creating a contact).
|
||||
- **Availability**: Supported on both **Cloud** and **Self-hosted** Chatwoot installations.
|
||||
- **Examples**:
|
||||
|
||||
- [Client API Demo](https://github.com/chatwoot/client-api-demo)
|
||||
- [Flutter SDK](https://github.com/chatwoot/chatwoot-flutter-sdk)
|
||||
|
||||
---
|
||||
|
||||
## Platform APIs
|
||||
|
||||
Platform APIs are used to manage Chatwoot installations at the admin level. These APIs allow you to control users, roles, and accounts, or sync data from external authentication systems.
|
||||
|
||||
- **Authentication**: Requires an `access_token` generated by a **Platform App**, which can be created in the **Super Admin Console**.
|
||||
- **Availability**: Available on **Self-hosted** / **Managed Hosting** Chatwoot installations only.
|
||||
|
||||
---
|
||||
|
||||
Use the right API for your use case, and you'll be able to extend, customize, and integrate Chatwoot into your stack with ease.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
+4
-4
@@ -1,6 +1,6 @@
|
||||
# pre-build stage
|
||||
FROM node:23-alpine as node
|
||||
FROM ruby:3.4.4-alpine3.21 AS pre-builder
|
||||
FROM ruby:3.3.3-alpine3.19 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.4.0/cache/*.gem \
|
||||
&& find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
|
||||
RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
|
||||
&& find /gems/ruby/3.3.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
|
||||
&& rm -rf .git \
|
||||
&& rm .gitignore
|
||||
|
||||
# final build stage
|
||||
FROM ruby:3.4.4-alpine3.21
|
||||
FROM ruby:3.3.3-alpine3.19
|
||||
|
||||
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
|
||||
|
||||
@@ -4,6 +4,29 @@ module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
before_action :set_assistant, only: [:copilot]
|
||||
end
|
||||
|
||||
def copilot
|
||||
# First try to get the user's preferred assistant from UI settings or from the request
|
||||
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
|
||||
|
||||
# Find the assistant either by ID or from inbox
|
||||
assistant = if assistant_id.present?
|
||||
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
|
||||
else
|
||||
@conversation.inbox.captain_assistant
|
||||
end
|
||||
|
||||
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
|
||||
|
||||
response = Captain::Copilot::ChatService.new(
|
||||
assistant,
|
||||
previous_messages: copilot_params[:previous_messages],
|
||||
conversation_history: @conversation.to_llm_text,
|
||||
language: @conversation.account.locale_english_name
|
||||
).generate_response(copilot_params[:message])
|
||||
|
||||
render json: { message: response['response'] }
|
||||
end
|
||||
|
||||
def inbox_assistant
|
||||
assistant = @conversation.inbox.captain_assistant
|
||||
|
||||
|
||||
@@ -12,13 +12,10 @@ module Captain::ChatHelper
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error { "[CAPTAIN][ChatCompletion] #{e}" }
|
||||
raise e
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.info { "[CAPTAIN][ChatCompletion] #{response}" }
|
||||
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
@@ -39,13 +36,11 @@ module Captain::ChatHelper
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
function_name = tool_call['function']['name']
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(tool_call_id, function_name)
|
||||
process_invalid_tool_call(tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,6 +56,10 @@ module Captain::ChatHelper
|
||||
}
|
||||
end
|
||||
|
||||
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',
|
||||
@@ -68,37 +67,4 @@ module Captain::ChatHelper
|
||||
content: content
|
||||
}
|
||||
end
|
||||
|
||||
def publish_to_stream(response)
|
||||
@stream_writer&.call(response)
|
||||
end
|
||||
|
||||
def execute_tool_call(tool_call_id, function_name, arguments)
|
||||
publish_to_stream(
|
||||
{
|
||||
response: { response: "Processing tool call #{function_name}" },
|
||||
type: 'tool_calls_start'
|
||||
}
|
||||
)
|
||||
result = @tool_registry.send(function_name, arguments.merge('user_id' => @user_id))
|
||||
append_tool_response(result, tool_call_id)
|
||||
publish_to_stream(
|
||||
{
|
||||
response: { response: "Received tool response #{function_name}" },
|
||||
type: 'tool_response',
|
||||
tool: function_name
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(tool_call_id, function_name)
|
||||
append_tool_response('Tool not implemented', tool_call_id)
|
||||
publish_to_stream(
|
||||
{
|
||||
response: { response: 'Tool not implemented' },
|
||||
type: 'tool_error',
|
||||
tool: function_name
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def message_content(message)
|
||||
return message.content if message.content.present?
|
||||
|
||||
return 'User has shared an attachment' if message.attachments.any?
|
||||
'User has shared an attachment' if message.attachments.any?
|
||||
|
||||
'User has shared a message without content'
|
||||
end
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
class Captain::ProcessCopilotMessageJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(assistant_id:, message:, options: {})
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Starting job for assistant_id=#{assistant_id}")
|
||||
ensure_assistant(assistant_id)
|
||||
ensure_user(options[:user_id])
|
||||
process_message(message, options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_assistant(assistant_id)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding assistant with id=#{assistant_id}")
|
||||
@assistant = Captain::Assistant.find(assistant_id)
|
||||
@account = @assistant.account
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found assistant for account_id=#{@account.id}")
|
||||
end
|
||||
|
||||
def ensure_user(user_id)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding user with id=#{user_id}")
|
||||
@user = @account.users.find(user_id)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found user #{@user.name}")
|
||||
end
|
||||
|
||||
def process_message(message, options)
|
||||
return unless @assistant
|
||||
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Processing message with conversation_id=#{options[:conversation_id]}")
|
||||
conversation = find_conversation(options[:conversation_id])
|
||||
generate_chat_response(message, conversation, options[:previous_history])
|
||||
end
|
||||
|
||||
def find_conversation(conversation_id)
|
||||
return unless conversation_id
|
||||
|
||||
@account.conversations.find_by(display_id: conversation_id)
|
||||
end
|
||||
|
||||
def generate_chat_response(message, conversation, previous_history)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Generating chat response for conversation_id=#{conversation&.display_id}, user_id=#{@user.id}")
|
||||
Captain::Copilot::ChatService.new(
|
||||
@assistant,
|
||||
previous_messages: previous_history || [],
|
||||
stream_writer: ->(data) { broadcast_response(data) },
|
||||
additional_info: {
|
||||
language: @account.locale_english_name,
|
||||
conversation_id: conversation&.display_id,
|
||||
contact_id: conversation&.contact_id,
|
||||
user_id: @user.id
|
||||
}
|
||||
).generate_response(message)
|
||||
end
|
||||
|
||||
def broadcast_response(data)
|
||||
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Broadcasting response to account_id=#{@account.id} user_id=#{@user.id}")
|
||||
ActionCable.server.broadcast(
|
||||
"copilot_#{@account.id}_#{@user.id}",
|
||||
{ event: 'copilot.response', data: data }
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,50 +1,27 @@
|
||||
require 'openai'
|
||||
|
||||
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
attr_reader :assistant, :language
|
||||
|
||||
def initialize(assistant, config = {})
|
||||
def initialize(assistant, config)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant)
|
||||
@stream_writer = config[:stream_writer]
|
||||
setup_additional_info(config)
|
||||
@conversation_history = config[:conversation_history]
|
||||
@previous_messages = config[:previous_messages] || []
|
||||
@language = config[:language] || 'english'
|
||||
|
||||
register_tools
|
||||
@messages = build_initial_messages(config)
|
||||
end
|
||||
|
||||
def setup_additional_info(config)
|
||||
additional_info = config[:additional_info] || {}
|
||||
@language = additional_info[:language] || 'english'
|
||||
@conversation_id = additional_info[:conversation_id]
|
||||
@contact_id = additional_info[:contact_id]
|
||||
@user_id = additional_info[:user_id]
|
||||
Rails.logger.info("[Captain::Copilot::ChatService::User] user_id: #{@user_id}")
|
||||
end
|
||||
|
||||
def build_initial_messages(config)
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Building initial messages for conversation_id=#{@conversation_id}")
|
||||
messages = [system_message]
|
||||
messages += (config[:previous_messages] || [])
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Added #{config[:previous_messages]&.length || 0} previous messages")
|
||||
messages << current_viewing_history if @conversation_id
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Total messages built: #{messages.length}")
|
||||
messages
|
||||
@messages = [system_message, conversation_history_context] + @previous_messages
|
||||
@response = ''
|
||||
end
|
||||
|
||||
def generate_response(input)
|
||||
@messages << { role: 'user', content: input } if input.present?
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Initial Prompt: #{@messages}")
|
||||
response = request_chat_completion
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
|
||||
@assistant.account.increment_response_usage
|
||||
|
||||
publish_to_stream(
|
||||
{
|
||||
response: response,
|
||||
type: 'final_response'
|
||||
}
|
||||
)
|
||||
response
|
||||
end
|
||||
|
||||
@@ -53,35 +30,22 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
def register_tools
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant)
|
||||
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
|
||||
end
|
||||
|
||||
def system_message
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Generating system message for product=#{@assistant.config['product_name']} language=#{@language}")
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language, @assistant.account_id)
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
|
||||
}
|
||||
end
|
||||
|
||||
def current_viewing_history
|
||||
Rails.logger.info("[CAPTAIN][CopilotChatService] Fetching viewing history for conversation_id=#{@conversation_id}")
|
||||
return unless @conversation_id
|
||||
|
||||
def conversation_history_context
|
||||
{
|
||||
role: 'system',
|
||||
content: <<~HISTORY.strip
|
||||
You are currently viewing the conversation with the user with the following details:
|
||||
Conversation ID: #{@conversation_id}
|
||||
Contact ID: #{@contact_id}
|
||||
Account ID: #{@assistant.account.id}
|
||||
HISTORY
|
||||
content: "
|
||||
Message History with the user is below:
|
||||
#{@conversation_history}
|
||||
"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -56,15 +56,14 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def copilot_response_generator(product_name, language, account_id)
|
||||
def copilot_response_generator(product_name, language)
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
[Identity]
|
||||
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
|
||||
You should only provide information related to #{product_name} and must not address queries about other products or external events.
|
||||
You are an assistant for the account with ID: #{account_id}
|
||||
|
||||
[Context]
|
||||
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
|
||||
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
|
||||
|
||||
[Response Guidelines]
|
||||
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
|
||||
@@ -91,9 +90,6 @@ class Captain::Llm::SystemPromptsService
|
||||
7. Write the response in multiple paragraphs and in markdown format.
|
||||
8. DO NOT use headings in Markdown
|
||||
9. Cite the sources if you used a tool to find the response.
|
||||
10. Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
|
||||
11. Always provide a reasoning for the response.
|
||||
12. Always double the check the information with tools unless you are very sure of the information.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
class Captain::ToolRegistryService
|
||||
attr_reader :registered_tools, :tools
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
def initialize(assistant)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
@registered_tools = []
|
||||
@tools = {}
|
||||
end
|
||||
|
||||
def register_tool(tool_class)
|
||||
tool = tool_class.new(@assistant, user: @user)
|
||||
tool = tool_class.new(@assistant)
|
||||
return unless tool.active?
|
||||
|
||||
@tools[tool.name] = tool
|
||||
@registered_tools << tool.to_registry_format
|
||||
end
|
||||
|
||||
def method_missing(method_name, *)
|
||||
def method_missing(method_name, *arguments)
|
||||
if @tools.key?(method_name.to_s)
|
||||
@tools[method_name.to_s].execute(*)
|
||||
@tools[method_name.to_s].execute(*arguments)
|
||||
else
|
||||
super
|
||||
end
|
||||
|
||||
@@ -36,18 +36,4 @@ class Captain::Tools::BaseService
|
||||
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
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
@@ -1,61 +0,0 @@
|
||||
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
|
||||
@@ -33,12 +33,6 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,6 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
VIMEO_REGEX = %r{https?://(?:www\.)?vimeo\.com/(\d+)}
|
||||
MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)}
|
||||
ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)}
|
||||
WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)}
|
||||
|
||||
def text(node)
|
||||
content = node.string_content
|
||||
@@ -51,8 +50,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
VIMEO_REGEX => :make_vimeo_embed,
|
||||
MP4_REGEX => :make_video_embed,
|
||||
LOOM_REGEX => :make_loom_embed,
|
||||
ARCADE_REGEX => :make_arcade_embed,
|
||||
WISTIA_REGEX => :make_wistia_embed
|
||||
ARCADE_REGEX => :make_arcade_embed
|
||||
}
|
||||
|
||||
embedding_methods.each do |regex, method|
|
||||
@@ -78,30 +76,67 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
|
||||
def make_youtube_embed(youtube_match)
|
||||
video_id = youtube_match[1]
|
||||
EmbedRenderer.youtube(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def make_loom_embed(loom_match)
|
||||
video_id = loom_match[1]
|
||||
EmbedRenderer.loom(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.loom.com/embed/#{video_id}"
|
||||
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def make_vimeo_embed(vimeo_match)
|
||||
video_id = vimeo_match[1]
|
||||
EmbedRenderer.vimeo(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def make_video_embed(link_url)
|
||||
EmbedRenderer.video(link_url)
|
||||
end
|
||||
|
||||
def make_wistia_embed(wistia_match)
|
||||
video_id = wistia_match[2]
|
||||
EmbedRenderer.wistia(video_id)
|
||||
%(
|
||||
<video width="640" height="360" controls>
|
||||
<source src="#{link_url}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
end
|
||||
|
||||
def make_arcade_embed(arcade_match)
|
||||
video_id = arcade_match[1]
|
||||
EmbedRenderer.arcade(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
allowfullscreen
|
||||
allow="fullscreen"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
module EmbedRenderer
|
||||
def self.youtube(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.loom(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://www.loom.com/embed/#{video_id}"
|
||||
frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.vimeo(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/#{video_id}?dnt=true"
|
||||
frameborder="0"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.video(link_url)
|
||||
%(
|
||||
<video width="640" height="360" controls>
|
||||
<source src="#{link_url}" type="video/mp4">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)
|
||||
end
|
||||
|
||||
# Generates an HTML embed for a Wistia video.
|
||||
# @param wistia_match [MatchData] A match object from the WISTIA_REGEX regex, where wistia_match[2] contains the video ID.
|
||||
def self.wistia(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
|
||||
<script src="https://fast.wistia.com/player.js" async></script>
|
||||
<script src="https://fast.wistia.com/embed/#{video_id}.js" async type="module"></script>
|
||||
<style>
|
||||
wistia-player[media-id='#{video_id}']:not(:defined) {
|
||||
background: center / contain no-repeat url('https://fast.wistia.com/embed/medias/#{video_id}/swatch');
|
||||
display: block;
|
||||
filter: blur(5px);
|
||||
padding-top:56.25%;
|
||||
}
|
||||
</style>
|
||||
<wistia-player
|
||||
media-id="#{video_id}"
|
||||
aspect="1.7777777777777777"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</wistia-player>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
|
||||
def self.arcade(video_id)
|
||||
%(
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/#{video_id}"
|
||||
frameborder="0"
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
allowfullscreen
|
||||
allow="fullscreen"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
)
|
||||
end
|
||||
end
|
||||
+10
-144
@@ -1,156 +1,22 @@
|
||||
require 'json_refs'
|
||||
require 'fileutils'
|
||||
require 'pathname'
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
namespace :swagger do
|
||||
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
|
||||
task build: :environment do
|
||||
require 'json_refs'
|
||||
|
||||
module SwaggerTaskActions
|
||||
def self.execute_build
|
||||
swagger_dir = Rails.root.join('swagger')
|
||||
# Paths relative to swagger_dir for use within Dir.chdir
|
||||
index_yml_relative_path = 'index.yml'
|
||||
swagger_json_relative_path = 'swagger.json'
|
||||
|
||||
Dir.chdir(swagger_dir) do
|
||||
# Operations within this block are relative to swagger_dir
|
||||
swagger_index_content = File.read(index_yml_relative_path)
|
||||
swagger_index = YAML.safe_load(swagger_index_content)
|
||||
base_path = Rails.root.join('swagger')
|
||||
Dir.chdir(base_path) do
|
||||
swagger_index = YAML.safe_load(File.open('index.yml'))
|
||||
|
||||
final_build = JsonRefs.call(
|
||||
swagger_index,
|
||||
resolve_local_ref: false,
|
||||
resolve_file_ref: true, # Uses CWD (swagger_dir) for resolving file refs
|
||||
resolve_file_ref: true,
|
||||
logging: true
|
||||
)
|
||||
File.write(swagger_json_relative_path, JSON.pretty_generate(final_build))
|
||||
|
||||
# For user messages, provide the absolute path
|
||||
absolute_swagger_json_path = swagger_dir.join(swagger_json_relative_path)
|
||||
File.write('swagger.json', JSON.pretty_generate(final_build))
|
||||
puts 'Swagger build was successful.'
|
||||
puts "Generated #{absolute_swagger_json_path}"
|
||||
puts "Generated #{base_path}/swagger.json"
|
||||
puts 'Go to http://localhost:3000/swagger see the changes.'
|
||||
|
||||
# Trigger dependent task
|
||||
Rake::Task['swagger:build_tag_groups'].invoke
|
||||
end
|
||||
end
|
||||
|
||||
def self.execute_build_tag_groups
|
||||
base_swagger_path = Rails.root.join('swagger')
|
||||
tag_groups_output_dir = base_swagger_path.join('tag_groups')
|
||||
full_spec_path = base_swagger_path.join('swagger.json')
|
||||
index_yml_path = base_swagger_path.join('index.yml')
|
||||
|
||||
full_spec = JSON.parse(File.read(full_spec_path))
|
||||
swagger_index = YAML.safe_load(File.read(index_yml_path))
|
||||
tag_groups = swagger_index['x-tagGroups']
|
||||
|
||||
FileUtils.mkdir_p(tag_groups_output_dir)
|
||||
|
||||
tag_groups.each do |tag_group|
|
||||
_process_tag_group(tag_group, full_spec, tag_groups_output_dir)
|
||||
end
|
||||
|
||||
puts 'Tag-specific swagger files generated successfully.'
|
||||
end
|
||||
|
||||
def self.execute_build_for_docs
|
||||
Rake::Task['swagger:build'].invoke # Ensure all swagger files are built first
|
||||
|
||||
developer_docs_public_path = Rails.root.join('developer-docs/public')
|
||||
tag_groups_in_dev_docs_path = developer_docs_public_path.join('swagger/tag_groups')
|
||||
source_tag_groups_path = Rails.root.join('swagger/tag_groups')
|
||||
|
||||
FileUtils.mkdir_p(tag_groups_in_dev_docs_path)
|
||||
puts 'Creating symlinks for developer-docs...'
|
||||
|
||||
symlink_files = %w[platform_swagger.json application_swagger.json client_swagger.json other_swagger.json]
|
||||
symlink_files.each do |file|
|
||||
_create_symlink(source_tag_groups_path.join(file), tag_groups_in_dev_docs_path.join(file))
|
||||
end
|
||||
|
||||
puts 'Symlinks created successfully.'
|
||||
puts 'You can now run the Mintlify dev server to preview the documentation.'
|
||||
end
|
||||
|
||||
# Private helper methods
|
||||
class << self
|
||||
private
|
||||
|
||||
def _process_tag_group(tag_group, full_spec, output_dir)
|
||||
group_name = tag_group['name']
|
||||
tags_in_current_group = tag_group['tags']
|
||||
|
||||
tag_spec = JSON.parse(JSON.generate(full_spec)) # Deep clone
|
||||
|
||||
tag_spec['paths'] = _filter_paths_for_tag_group(tag_spec['paths'], tags_in_current_group)
|
||||
tag_spec['tags'] = _filter_tags_for_tag_group(tag_spec['tags'], tags_in_current_group)
|
||||
|
||||
output_filename = _determine_output_filename(group_name)
|
||||
File.write(output_dir.join(output_filename), JSON.pretty_generate(tag_spec))
|
||||
end
|
||||
|
||||
def _operation_has_matching_tags?(operation, tags_in_group)
|
||||
return false unless operation.is_a?(Hash)
|
||||
|
||||
operation_tags = operation['tags']
|
||||
return false unless operation_tags.is_a?(Array)
|
||||
|
||||
operation_tags.intersect?(tags_in_group)
|
||||
end
|
||||
|
||||
def _filter_paths_for_tag_group(paths_spec, tags_in_group)
|
||||
(paths_spec || {}).filter_map do |path, path_item|
|
||||
next unless path_item.is_a?(Hash)
|
||||
|
||||
operations_with_group_tags = path_item.any? do |_method, operation|
|
||||
_operation_has_matching_tags?(operation, tags_in_group)
|
||||
end
|
||||
[path, path_item] if operations_with_group_tags
|
||||
end.to_h
|
||||
end
|
||||
|
||||
def _filter_tags_for_tag_group(tags_spec, tags_in_group)
|
||||
if tags_spec.is_a?(Array)
|
||||
tags_spec.select { |tag_definition| tags_in_group.include?(tag_definition['name']) }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def _determine_output_filename(group_name)
|
||||
return 'other_swagger.json' if group_name.casecmp('others').zero?
|
||||
|
||||
sanitized_group_name = group_name.downcase.tr(' ', '_').gsub(/[^a-z0-9_]+/, '')
|
||||
"#{sanitized_group_name}_swagger.json"
|
||||
end
|
||||
|
||||
def _create_symlink(source_file_path, target_file_path)
|
||||
FileUtils.rm_f(target_file_path) # Remove existing to avoid errors
|
||||
|
||||
if File.exist?(source_file_path)
|
||||
relative_source_path = Pathname.new(source_file_path).relative_path_from(target_file_path.dirname)
|
||||
FileUtils.ln_sf(relative_source_path, target_file_path)
|
||||
else
|
||||
puts "Warning: Source file #{source_file_path} not found. Skipping symlink for #{File.basename(target_file_path)}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :swagger do
|
||||
desc 'build combined swagger.json file from all the fragmented definitions and paths inside swagger folder'
|
||||
task build: :environment do
|
||||
SwaggerTaskActions.execute_build
|
||||
end
|
||||
|
||||
desc 'build separate swagger files for each tag group'
|
||||
task build_tag_groups: :environment do
|
||||
SwaggerTaskActions.execute_build_tag_groups
|
||||
end
|
||||
|
||||
desc 'build swagger files and create symlinks in developer-docs'
|
||||
task build_for_docs: :environment do
|
||||
SwaggerTaskActions.execute_build_for_docs
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,18 +79,6 @@ 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
|
||||
@@ -163,15 +151,11 @@ 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,22 +189,19 @@ 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
|
||||
|
||||
# Conversation should exist but no new message should be created
|
||||
# we would have contact created but message and attachments won't 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
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
describe '#aggregate_value' do
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,54 +76,6 @@ 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
|
||||
|
||||
@@ -4,7 +4,7 @@ require 'rails_helper'
|
||||
class TestTool < Captain::Tools::BaseService
|
||||
attr_accessor :tool_active
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
def initialize(*args)
|
||||
super
|
||||
@tool_active = true
|
||||
end
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
@@ -1,110 +0,0 @@
|
||||
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
|
||||
@@ -1,142 +0,0 @@
|
||||
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
|
||||
@@ -1,167 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchArticlesService 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('search_articles')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search articles based on parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
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'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
)
|
||||
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 is an agent' do
|
||||
let(:user) { create(:user, 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 query is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({})).to eq('Missing required parameters')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no articles are found' do
|
||||
before do
|
||||
allow(Article).to receive(:where).and_return(Article.none)
|
||||
end
|
||||
|
||||
it 'returns no articles found message' do
|
||||
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when articles are found' do
|
||||
let(:portal) { create(:portal, account: account) }
|
||||
let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
|
||||
let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
|
||||
|
||||
before do
|
||||
article1
|
||||
article2
|
||||
end
|
||||
|
||||
it 'returns formatted articles with count' do
|
||||
result = service.execute({ 'query' => 'Test' })
|
||||
expect(result).to include('Total number of articles: 2')
|
||||
expect(result).to include(article1.to_llm_text)
|
||||
expect(result).to include(article2.to_llm_text)
|
||||
end
|
||||
|
||||
context 'when filtered by category' do
|
||||
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
|
||||
let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
|
||||
|
||||
before do
|
||||
article3
|
||||
end
|
||||
|
||||
it 'returns only articles from the specified category' do
|
||||
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
|
||||
expect(result).to include('Total number of articles: 1')
|
||||
expect(result).to include(article3.to_llm_text)
|
||||
expect(result).not_to include(article1.to_llm_text)
|
||||
expect(result).not_to include(article2.to_llm_text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtered by status' do
|
||||
let(:article3) do
|
||||
create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published')
|
||||
end
|
||||
let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
|
||||
|
||||
before do
|
||||
article3
|
||||
article4
|
||||
end
|
||||
|
||||
it 'returns only articles with the specified status' do
|
||||
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
|
||||
expect(result).to include(article3.to_llm_text)
|
||||
expect(result).not_to include(article4.to_llm_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,113 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::Copilot::SearchContactsService 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('search_contacts')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the service description' do
|
||||
expect(service.description).to eq('Search contacts based on query parameters')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
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)'
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user has 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 does not have 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 contacts are found' do
|
||||
let(:contact1) { create(:contact, account: account, email: 'test1@example.com', name: 'Test Contact 1', phone_number: '+1234567890') }
|
||||
let(:contact2) { create(:contact, account: account, email: 'test2@example.com', name: 'Test Contact 2', phone_number: '+1234567891') }
|
||||
|
||||
before do
|
||||
contact1
|
||||
contact2
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by email' do
|
||||
result = service.execute({ 'email' => 'test1@example.com' })
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by phone number' do
|
||||
result = service.execute({ 'phone_number' => '+1234567890' })
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns contacts when filtered by name' do
|
||||
result = service.execute({ 'name' => 'Contact 1' })
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).not_to include(contact2.to_llm_text)
|
||||
end
|
||||
|
||||
it 'returns all matching contacts when no filters are provided' do
|
||||
result = service.execute({})
|
||||
expect(result).to include(contact1.to_llm_text)
|
||||
expect(result).to include(contact2.to_llm_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,64 +26,6 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
context 'when user has conversation_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
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 conversation_unassigned_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
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 conversation_participating_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
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 no relevant conversation permissions' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
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
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
|
||||
|
||||
@@ -143,17 +143,6 @@ describe CustomMarkdownRenderer do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a wistia URL' do
|
||||
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
|
||||
|
||||
it 'renders a custom element with Wistia embed code' do
|
||||
output = render_markdown_link(wistia_url)
|
||||
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
|
||||
expect(output).to include('<wistia-player')
|
||||
expect(output).to include('media-id="kjwjeq6f9i"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple links including Arcade are present' do
|
||||
it 'renders Arcade embed along with other content types' do
|
||||
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
|
||||
|
||||
@@ -793,8 +793,8 @@ RSpec.describe Conversation do
|
||||
end
|
||||
|
||||
context 'when a new conversation is created' do
|
||||
it 'sets last_activity_at to the created_at time (within DB precision)' do
|
||||
expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
|
||||
it 'sets last_activity_at to the created_at time' do
|
||||
expect(conversation.last_activity_at).to eq(conversation.created_at)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
# the phone number is intentionally wrong
|
||||
phone_number: '+1234567890'
|
||||
)
|
||||
|
||||
@@ -30,19 +29,6 @@ RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do
|
||||
'Source' => 'Test Brand'
|
||||
)
|
||||
end
|
||||
|
||||
it 'represents the phone number correctly' do
|
||||
contact.update!(
|
||||
name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john@example.com',
|
||||
phone_number: '+917507684392'
|
||||
)
|
||||
|
||||
mapped_data = described_class.map(contact)
|
||||
|
||||
expect(mapped_data).to include('Mobile' => '+91-7507684392')
|
||||
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