Compare commits

..
Author SHA1 Message Date
Shivam Mishra 8bf39a52d1 feat: get rid of turbo links 2025-05-23 12:28:47 +05:30
Shivam Mishra 5e570d08f3 feat: better search UX on the page 2025-05-23 12:26:22 +05:30
Shivam Mishra 8070b0897c feat: use turbo instead of turbo links 2025-05-23 12:25:05 +05:30
Shivam MishraandGitHub 45a1b44b61 Merge branch 'develop' into feature/10945-search-results-page 2025-05-23 11:52:51 +05:30
PranavandGitHub 72c5671e09 feat: Add support for the temperature field (#11554)
<img width="460" alt="Screenshot 2025-05-22 at 3 10 22 PM"
src="https://github.com/user-attachments/assets/e1c6bbd7-cd28-4808-99cb-ebc322531987"
/>


This is a stop-gap solution.
2025-05-22 23:03:10 -07:00
PranavandGitHub 8c0885e1d2 feat: Add support for realtime-events in copilot-threads and copilot-messages (#11557)
- Add API support for creating a thread
- Add API support for creating a message
- Remove uuid from thread (no longer required, we will use existing
websocket connection to send messages)
- Update message_type to a column (user, assistant, assistant_thinking)
2025-05-22 22:25:05 -07:00
Sivin VargheseandGitHub e92f72b318 chore: Prevent online presence during impersonation (#11538) 2025-05-23 09:53:40 +05:30
PranavandGitHub ea04a292ed chore: Add an introduction guide to the developer docs (#11553) 2025-05-22 11:02:52 -07:00
Shivam MishraandGitHub deeb6d4e3f fix: Ignore private notes from the last 5 min when determining if an out of office message should be sent (#11552)
A while back, we added a UX enhancement that disabled sending the OOO
message when an agent had replied to the conversation in the last 5
minutes.

https://github.com/chatwoot/chatwoot/pull/11073

This would not send a "we will be back" message even if that reply was a
private note. This would break for bots that posted private notes, and
even for agents who need to just add a note and not actually reply to
the email.
2025-05-22 08:50:28 -07:00
a3bf753f4c feat: add embed for wistia (#11547)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-22 19:09:03 +05:30
459f225559 feat: Update swagger to openapi 3.0.4, update request payloads with examples (#11533)
# Pull Request Template

## Description

Updates the public swagger spec to be OpenAPI 3.0.4 compliant. Regarding
#7893, I'm investigating the use of
[oas_rails](https://github.com/a-chacon/oas_rails) to auto-generate the
documentation along with correct expected payloads.
Mostly fixes #10531, related to #7893

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Copied spec into swagger editor, reviewed outputs:

![openapi](https://github.com/user-attachments/assets/bd43f895-74bb-4373-ba6d-c5b190b699dd)

Note that this shows two errors with the `DELETE` endpoints as
technically these should not have a `requestBody` - in which case we
should be making use of another HTTP verb or another endpoint.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Daniel Jimenez <devildan.knex@gmail.com>
Co-authored-by: Daniel Jimenez <daniel.jimenez@spark64.com>
2025-05-22 17:57:12 +07:00
99de5f4257 fix: phone number handling in leadsquared (#11527)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-05-22 13:34:27 +05:30
Daniel JimenezandGitHub 4b417ce9e7 feat: Update swagger to openapi 3.0.4, update request payloads with examples (#11374) 2025-05-22 14:26:44 +07:00
8697a30dc5 feat: Ability to access user tokens via Platform API (#11537)
- Add Platform API for generating user tokens 
- Add the swagger documentation.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2025-05-22 11:30:04 +05:30
Muhsin KelothandGitHub 8565341682 fix: Prevent creating duplicate messages via Instagram echo events (#11535)
Fixes
https://linear.app/chatwoot/issue/CW-4383/copilot-resolution-message-creating-duplicate-conversations-due-to
and
https://linear.app/chatwoot/issue/CW-4287/instagram-channel-missing-existing-source-id-check

When the copilot/system resolves a conversation with a system resolve
message (with `lock_to_single_conversation` disabled), echo events were
creating new conversations instead of using existing ones. This occurred
because we were checking the echo_id in the new conversation rather than
in the resolved conversation. This PR fixes the issue by checking if the
message exists anywhere in the system instead of checking within a
particular conversation.
2025-05-22 11:02:28 +05:30
Shivam MishraandGitHub 9bd7daeaf8 feat: Allow CORS api access (#11546) 2025-05-22 10:13:15 +05:30
Sojan JoseandGitHub abe22c8649 fix: Rack-attack disable double Redis pooling (#11545)
Rails 7.1 ships with connection-pooling enabled by default for
`RedisCacheStore` (see rails/rails#45235).
Because we already wrap our Redis clients in our own `ConnectionPool`
($alfred / $velma), the upgrade resulted in a double-wrapped object and
runtime errors such as:

NoMethodError: undefined method `get` for an instance of ConnectionPool

This patch:

* Passes `pool: false` when instantiating `RedisCacheStore` in
`config/initializers/rack_attack.rb`, telling Rails to use the pool we
supply instead of building its own.
* Adds an inline comment explaining the rationale.
* Adds a TODO in `config/initializers/01_redis.rb` suggesting a future
simplification: switch to plain Redis clients and let Rails manage the
pool.

Reference docs:
* rails/rails#45235 – “Enable connection pooling by default for
MemCacheStore and RedisCacheStore” -
https://github.com/rails/rails/pull/45235
* Rails 7.1 Caching Guide – 2.1.1 “Connection Pool Options” (use `pool:
false`) [Ruby on Rails
Guides](https://guides.rubyonrails.org/v7.1/caching_with_rails.html)
2025-05-21 20:00:11 -07:00
PranavandGitHub 743b73d0af chore: Update Copilot UI in favor of the new design (#11544)
Removes avatar from the components.

**Before:**
<img width="283" alt="Screenshot 2025-05-21 at 3 58 10 PM"
src="https://github.com/user-attachments/assets/8f25343f-e24a-4d39-b8e8-6e10610cd8da"
/>


**After:** 
<img width="286" alt="Screenshot 2025-05-21 at 3 57 29 PM"
src="https://github.com/user-attachments/assets/f3704dc9-d270-4e6e-94c5-6c7aa0903b24"
/>
2025-05-21 18:14:51 -07:00
PranavandGitHub dc7f1597e5 feat: Add support for additional tools in Copilot (#11531)
- Added GetConversation, GetContact, GetArticle, SearchContacts,
SearchArticles
- Update SearchConversations to handle the permissions properly.
2025-05-21 15:49:35 -07:00
Sojan JoseandGitHub bc42aec68e chore: upgrade ruby version to 3.4.4 (#11524)
- Chore upgrade ruby version to 3.4.4 before we migrate to rails 7.2
over #11037
2025-05-21 19:40:07 +05:30
3c8abd5b30 fix: Twilio authentication handling for WhatsApp attachments (#11536)
# Pull Request Template

## Description

This PR addresses an issue where users were unable to view images sent
via WhatsApp on Chatwoot due to incorrect Twilio authentication
configuration.
https://app.chatwoot.com/app/accounts/1/conversations/50824

The problem stemmed from how authentication was being handled for Twilio
API requests. The user had configured their inbox using api_key_sid, but
the backend logic used only auth_token, leading to failed
authentication. Further investigation showed that some customers might
input api_secret into the auth_token field unintentionally.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Tested on console with Client(api_key_sid, auth_token, account_sid)
and validated successful authentication for the customer (Twilio channel
ID: 2702).
- Simulated toggling the “Use API Key Authentication” checkbox to ensure
backend behavior matches UI intent
- Verified image rendering by testing with the same image URL that was
previously failing for the user.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-05-21 18:40:15 +05:30
1602b071db feat: Add components to show steps in the copilot thinking process (#11530)
This PR adds the components for new Copilot UI
- Added a Header component
- Added a thinking block.
- Update the outline on copilot input

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-20 21:35:29 -07:00
4664402bf3 feat: Delete a contact from the contacts page (#11529)
# Pull Request Template

## Description


**This PR includes:**

1. Adds the ability to delete a contact from the contacts list accordion
section.
2. Improves the expand/collapse transition for the accordion.


Fixes
[CW-4375](https://linear.app/chatwoot/issue/CW-4375/allow-users-to-delete-a-contact-from-the-contacts-page)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/8c897d24737f40f6b8b29fef76ba18e2?sid=70910b9d-f3db-4d54-8bfc-820db680e537


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-05-20 21:35:14 -07:00
df7401f71c fix: account email validation during signup (#11307)
- Refactor email validation logic to be a service
- Use the service for both email/pass signup and Google SSO
- fix account email validation during signup
- Use `blocked_domain` setting for both email/pass signup and Google
Sign In [`BLOCKED_DOMAIN` via GlobalConfig]
- add specs for `account_builder`
- add specs for the new service

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-20 20:45:39 -07:00
a07f2a7c1b feat: Add support for search_conversations in copilot (#11520)
Earlier, we were manually checking if a user was an agent and filtering
their conversations based on inboxes. This logic should have been part
of the conversation permissions service.

This PR moves the check to the right place and updates the logic
accordingly.

Other updates:
- Add support for search_conversations service for copilot.
- Use PermissionFilterService in contacts/conversations, conversations,
copilot search_conversations.

---------

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-05-20 19:22:17 -07:00
Sivin VargheseandGitHub af650af489 chore: Display Agent Bot token after creation (#11488)
This PR includes:

- Displaying the Agent Bot token after creation
- Updating the avatar icon when an avatar image is not present

Fixes: https://linear.app/chatwoot/issue/CW-4337/agent-bot-token-not-visible
2025-05-20 17:35:18 -07:00
Sivin VargheseandGitHub 2ee63656e2 feat: Prevent saving preferences and status when impersonating (#11164)
This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings.

Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements
2025-05-20 17:34:30 -07:00
Muhsin KelothandGitHub 27ec791353 fix: Display message content for CSAT messages in non-widget inboxes (#11528)
We made so many improvements for CSAT via https://github.com/chatwoot/chatwoot/pull/11485. However, we missed showing message content in the dashboard for CSAT URLs created in non-widget inboxes. This PR fixes the issue by ensuring that CSAT-configured messages are passed along with CSAT responses, otherwise defaulting to the translation.
2025-05-20 15:39:18 -07:00
Sivin VargheseandGitHub ccb3672ff8 fix: Status not updating when creating a Linear issue (#11523) 2025-05-20 16:04:56 +05:30
Sojan 54faefb876 Merge branch 'release/4.2.0' into develop 2025-05-20 00:12:15 -07:00
AlexandGitHub 076059cd3c Merge branch 'develop' into feature/10945-search-results-page 2025-03-19 12:25:14 +01:00
Alejandro Fanjul 67311c2c9a feat: Implement search results functionality with improved UX in PublicArticleSearch component
- Now you can use /hc/{account}/en/search?query=XXX to view search results
2025-03-14 14:08:56 +01:00
337 changed files with 40924 additions and 6676 deletions
+3 -3
View File
@@ -73,15 +73,15 @@ jobs:
libvips
- run:
name: Install RVM and Ruby 3.3.3
name: Install RVM and Ruby 3.4.4
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.3.3"
rvm use 3.3.3 --default
rvm install "3.4.4"
rvm use 3.4.4 --default
gem install bundler -v 2.5.16
- run:
+2 -2
View File
@@ -12,7 +12,7 @@ services:
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
@@ -25,7 +25,7 @@ services:
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
+179 -8
View File
@@ -1,7 +1,10 @@
require:
plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
@@ -13,44 +16,61 @@ Metrics/ClassLength:
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
Enabled: false
Style/ExponentialNotation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SymbolArray:
Enabled: false
Style/OpenStructUse:
Enabled: false
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb'
Style/GlobalVars:
Exclude:
- 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb'
- 'lib/global_config.rb'
Style/ClassVars:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper:
Exclude:
- 'app/drops/base_drop.rb'
Lint/SymbolConversion:
Enabled: false
Lint/EmptyBlock:
Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant:
Exclude:
- 'lib/redis/config.rb'
Metrics/BlockLength:
Max: 30
Exclude:
@@ -58,10 +78,16 @@ Metrics/BlockLength:
- '**/routes.rb'
- 'config/environments/*'
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -71,74 +97,101 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Rails/InverseOf:
Exclude:
- enterprise/app/models/captain/assistant.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- app/models/canned_response.rb
- app/models/telegram_bot.rb
- enterprise/app/models/captain_inbox.rb
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:
- 'config/application.rb'
- 'config/initializers/monkey_patches/*'
Style/MapToHash:
Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never
RSpec/NestedGroups:
Enabled: true
Max: 4
RSpec/MessageSpies:
Enabled: false
RSpec/StubbedMock:
Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber:
Enabled: false
Naming/MemoizedInstanceVariableName:
Exclude:
- 'app/models/message.rb'
Style/GuardClause:
Exclude:
- 'app/builders/account_builder.rb'
- 'app/models/attachment.rb'
- 'app/models/message.rb'
Metrics/AbcSize:
Max: 26
Exclude:
- 'app/controllers/concerns/auth_helper.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
Rails/RenderInline:
Exclude:
- 'app/controllers/swagger_controller.rb'
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet:
Enabled: false
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleExpectations:
Max: 7
RSpec/MultipleMemoizedHelpers:
Max: 14
@@ -166,3 +219,121 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
FactoryBot/SyntaxMethods:
Enabled: false
# Disable new rules causing errors
Layout/LeadingCommentSpace:
Enabled: false
Style/ReturnNilInPredicateMethodDefinition:
Enabled: false
Style/RedundantParentheses:
Enabled: false
Performance/StringIdentifierArgument:
Enabled: false
Layout/EmptyLinesAroundExceptionHandlingKeywords:
Enabled: false
Lint/LiteralAsCondition:
Enabled: false
Style/RedundantReturn:
Enabled: false
Layout/SpaceAroundOperators:
Enabled: false
Rails/EnvLocal:
Enabled: false
Rails/WhereRange:
Enabled: false
Lint/UselessConstantScoping:
Enabled: false
Style/MultipleComparison:
Enabled: false
Bundler/OrderedGems:
Enabled: false
RSpec/ExampleWording:
Enabled: false
RSpec/ReceiveMessages:
Enabled: false
FactoryBot/AssociationStyle:
Enabled: false
Rails/EnumSyntax:
Enabled: false
Lint/RedundantTypeConversion:
Enabled: false
# Additional rules to disable
Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Style/SafeNavigationChainLength:
Enabled: false
Lint/SafeNavigationConsistency:
Enabled: false
Lint/CopDirectiveSyntax:
Enabled: false
# Final set of rules to disable
FactoryBot/ExcessiveCreateList:
Enabled: false
RSpec/MissingExpectationTargetMethod:
Enabled: false
Performance/InefficientHashSearch:
Enabled: false
Style/RedundantSelfAssignmentBranch:
Enabled: false
Style/YAMLFileRead:
Enabled: false
Layout/ExtraSpacing:
Enabled: false
Style/RedundantFilterChain:
Enabled: false
Performance/MapMethodChain:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
Style/SuperArguments:
Enabled: false
# Final remaining rules to disable
Rails/Delegate:
Enabled: false
Style/CaseLikeIf:
Enabled: false
FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
+1 -1
View File
@@ -1 +1 @@
3.3.3
3.4.4
+7 -5
View File
@@ -1,12 +1,14 @@
source 'https://rubygems.org'
ruby '3.3.3'
ruby '3.4.4'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.0.8.4'
gem 'rails', '~> 7.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
# Turbo for SPA-like page acceleration
gem 'turbo-rails'
##-- rails application helper gems --##
gem 'acts-as-taggable-on'
@@ -33,6 +35,8 @@ 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
@@ -196,9 +200,6 @@ 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'
@@ -237,6 +238,7 @@ 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
+164 -140
View File
@@ -25,76 +25,89 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
actioncable (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
zeitwerk (~> 2.6)
actionmailbox (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.7)
actionpack (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activesupport (= 7.0.8.7)
actionmailer (7.1.5.1)
actionpack (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activesupport (= 7.1.5.1)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.7)
actionview (= 7.0.8.7)
activesupport (= 7.0.8.7)
rack (~> 2.0, >= 2.2.4)
rails-dom-testing (~> 2.2)
actionpack (7.1.5.1)
actionview (= 7.1.5.1)
activesupport (= 7.1.5.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.7)
actionpack (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.1)
actionpack (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.7)
activesupport (= 7.0.8.7)
actionview (7.1.5.1)
activesupport (= 7.1.5.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
active_record_query_trace (1.8)
activejob (7.0.8.7)
activesupport (= 7.0.8.7)
activejob (7.1.5.1)
activesupport (= 7.1.5.1)
globalid (>= 0.3.6)
activemodel (7.0.8.7)
activesupport (= 7.0.8.7)
activerecord (7.0.8.7)
activemodel (= 7.0.8.7)
activesupport (= 7.0.8.7)
activerecord-import (1.4.1)
activemodel (7.1.5.1)
activesupport (= 7.1.5.1)
activerecord (7.1.5.1)
activemodel (= 7.1.5.1)
activesupport (= 7.1.5.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activerecord (>= 4.2)
activestorage (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activesupport (= 7.0.8.7)
activestorage (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activesupport (= 7.1.5.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.7)
activesupport (7.1.5.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0)
acts-as-taggable-on (9.0.1)
activerecord (>= 6.0, < 7.1)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1)
@@ -116,7 +129,7 @@ GEM
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
ast (2.4.3)
attr_extras (7.1.0)
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
@@ -142,14 +155,15 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.8)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
browser (5.3.1)
builder (3.3.0)
bullet (7.0.7)
bullet (8.0.7)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
@@ -161,8 +175,8 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
rexml
@@ -176,16 +190,10 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog-ci (0.8.3)
msgpack
date (3.4.1)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
ddtrace (0.48.0)
ffi (~> 1.0)
msgpack
debase-ruby_core_source (3.3.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -196,10 +204,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_token_auth (1.2.3)
devise_token_auth (1.2.5)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
rails (>= 4.2.0, < 8.1)
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
@@ -212,6 +220,7 @@ GEM
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
@@ -254,7 +263,10 @@ GEM
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.16.3)
ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -315,16 +327,13 @@ GEM
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.5)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
google-protobuf (3.25.7)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
@@ -334,17 +343,17 @@ GEM
signet (>= 0.16, < 2.a)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.62.0)
google-protobuf (~> 3.25)
grpc (1.72.0)
google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.25)
grpc (1.72.0-arm64-darwin)
google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.25)
grpc (1.72.0-x86_64-darwin)
google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0)
grpc (1.62.0-x86_64-linux)
google-protobuf (~> 3.25)
grpc (1.72.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (1.0.0)
@@ -370,7 +379,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.6)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -388,7 +397,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.6.3)
json (2.12.0)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -423,21 +432,13 @@ GEM
faraday-multipart
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2)
addressable (~> 2.8)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
@@ -445,7 +446,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.0)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@@ -471,10 +472,10 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
minitest (5.25.5)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
@@ -545,14 +546,16 @@ GEM
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
parallel (1.23.0)
parser (3.2.2.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pg (1.5.3)
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
pgvector (0.1.1)
prism (1.4.0)
procore-sift (1.0.0)
activerecord (>= 6.1)
pry (0.14.2)
@@ -567,7 +570,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.14)
rack (2.2.15)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -581,23 +584,28 @@ GEM
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7)
rack
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.7)
actioncable (= 7.0.8.7)
actionmailbox (= 7.0.8.7)
actionmailer (= 7.0.8.7)
actionpack (= 7.0.8.7)
actiontext (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activemodel (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
rackup (1.0.1)
rack (< 3)
webrick
rails (7.1.5.1)
actioncable (= 7.1.5.1)
actionmailbox (= 7.1.5.1)
actionmailer (= 7.1.5.1)
actionpack (= 7.1.5.1)
actiontext (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activemodel (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
bundler (>= 1.15.0)
railties (= 7.0.8.7)
railties (= 7.1.5.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -605,13 +613,14 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
method_source
railties (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
@@ -623,7 +632,7 @@ GEM
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.8.0)
regexp_parser (2.10.0)
reline (0.3.6)
io-console (~> 0.5)
representable (3.2.0)
@@ -643,7 +652,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.9)
rexml (3.4.1)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -663,30 +672,36 @@ GEM
rspec-support (3.13.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2)
rubocop (1.75.6)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.2.0.0)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.28.1)
parser (>= 3.2.1.0)
rubocop-capybara (2.18.0)
rubocop (~> 1.41)
rubocop-performance (1.17.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.19.1)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.21.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
@@ -800,6 +815,9 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -816,8 +834,10 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3)
uri_template (0.7.0)
valid_email2 (5.2.6)
@@ -845,7 +865,9 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.6)
webrick (1.9.1)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
@@ -951,7 +973,7 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.0.8.4)
rails (~> 7.1)
redis
redis-namespace
responders (>= 3.1.1)
@@ -960,6 +982,7 @@ DEPENDENCIES
rspec-rails (>= 6.1.5)
rspec_junit_formatter
rubocop
rubocop-factory_bot
rubocop-performance
rubocop-rails
rubocop-rspec
@@ -984,6 +1007,7 @@ DEPENDENCIES
telephone_number
test-prof
time_diff
turbo-rails
twilio-ruby (~> 5.66)
twitty (~> 0.1.5)
tzinfo-data
@@ -997,7 +1021,7 @@ DEPENDENCIES
working_hours
RUBY VERSION
ruby 3.3.3p89
ruby 3.4.4p34
BUNDLED WITH
2.5.16
+4 -2
View File
@@ -59,11 +59,13 @@ class ContactInboxBuilder
end
def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
attrs = {
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: @source_id
)
}
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
@@ -152,11 +152,13 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
end
def message_already_exists?
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
find_message_by_source_id(@messaging[:message][:mid]).present?
end
cw_message.present?
def find_message_by_source_id(source_id)
return unless source_id
@message = Message.find_by(source_id: source_id)
end
def all_unsupported_files?
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
@@ -1,9 +1,9 @@
class Platform::Api::V1::UsersController < PlatformController
# ref: https://stackoverflow.com/a/45190318/939299
# set resource is called for other actions already in platform controller
# we want to add login to that chain as well
before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible }
# we want to add login and token to that chain as well
before_action(only: [:login, :token]) { set_resource }
before_action(only: [:login, :token]) { validate_platform_app_permissible }
def show; end
@@ -18,6 +18,8 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link }
end
def token; end
def update
@resource.assign_attributes(user_update_params)
@@ -0,0 +1,19 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :portal
layout 'portal'
def index
@query = params[:query]
@articles = @portal.articles.published
@articles = @articles.search(search_params) if @query.present?
@articles = @articles.page(params[:page]).per(10) # Add pagination
end
private
def search_params
params.permit(:query, :locale, :sort, :status, :page)
end
end
@@ -42,6 +42,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
@@ -212,7 +215,7 @@ watch(
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -221,6 +224,25 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
@@ -1,31 +1,17 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
supportAgent: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
:name="supportAgent.available_name"
:src="supportAgent.avatar_url"
:size="24"
rounded-full
/>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div>
</template>
@@ -9,7 +9,6 @@ import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
const props = defineProps({
message: {
@@ -46,33 +45,25 @@ const useCopilotResponse = () => {
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
name="Captain Copilot"
icon-name="i-woot-captain"
:size="24"
rounded-full
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
</span>
<div
v-else
v-dompurify-html="messageContent"
class="prose-sm break-words"
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
slate
@click="useCopilotResponse"
/>
</div>
</div>
</div>
</template>
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -52,6 +55,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onPresenceUpdate = data => {
if (isImpersonating.value) return;
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
@@ -367,7 +367,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -410,6 +409,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
+3 -3
View File
@@ -1,9 +1,9 @@
import Rails from '@rails/ujs';
import Turbolinks from 'turbolinks';
import { Turbo } from '@hotwired/turbo-rails';
import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start();
Turbolinks.start();
Turbo.start();
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
@@ -90,18 +90,29 @@ export default {
this.isLoading = false;
}
},
handleSubmit() {
if (this.searchTerm.trim()) {
const url = `/hc/${this.portalSlug}/${this.localeCode}/search?query=${encodeURIComponent(this.searchTerm)}`;
window.location.href = url;
}
},
},
};
</script>
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<form @submit.prevent="handleSubmit">
<PublicSearchInput
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<button type="submit" class="sr-only">
{{ searchTranslations.submit || 'Search' }}
</button>
</form>
<div
v-if="shouldShowSearchBox"
class="absolute w-full top-14"
@@ -110,7 +110,7 @@ export default {
<p class="py-1 px-3" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
data-turbo="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
+2 -2
View File
@@ -15,7 +15,7 @@ export const getHeadingsfromTheArticle = () => {
const slug = slugifyWithCounter(element.innerText);
element.id = slug;
element.className = 'scroll-mt-24 heading';
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbo="false">#</a>`;
rows.push({
slug,
title: element.innerText,
@@ -144,7 +144,7 @@ export const InitializationHelpers = {
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbolinks'] = false;
a['data-turbo'] = false;
a.click();
}
},
+2
View File
@@ -204,3 +204,5 @@ class ActionCableListener < BaseListener
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
end
end
ActionCableListener.prepend_mod_with('ActionCableListener')
+1 -1
View File
@@ -31,7 +31,7 @@ class ApplicationMailbox < ActionMailbox::Base
end
def in_reply_to_matches?(in_reply_to)
Array.wrap(in_reply_to).any? { _1.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
end
# checks if follow this pattern send it to reply_mailbox
+2 -2
View File
@@ -97,8 +97,8 @@ class Account < ApplicationRecord
has_one_attached :contacts_export
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum status: { active: 0, suspended: 1 }
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :status, { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
+6
View File
@@ -128,6 +128,12 @@ class Conversation < ApplicationRecord
additional_attributes&.dig('conversation_language')
end
# Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
# Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
# if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
# if you compare or copy these values in Ruby, also in our specs
# So in specs rely on to be_with(1.second) instead of to eq()
# TODO: Migrate to use a timestamp with microsecond precision
def last_activity_at
self[:last_activity_at] || created_at
end
+2 -2
View File
@@ -16,8 +16,8 @@
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE
#
class EmailTemplate < ApplicationRecord
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum template_type: { layout: 0, content: 1 }
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account }
+5 -1
View File
@@ -19,7 +19,7 @@ class InstallationConfig < ApplicationRecord
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
# Fix configuration in application.rb
serialize :serialized_value, ActiveSupport::HashWithIndifferentAccess
serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess
before_validation :set_lock
validates :name, presence: true
@@ -32,6 +32,10 @@ class InstallationConfig < ApplicationRecord
after_commit :clear_cache
def value
# This is an extra hack again cause of the YAML serialization, in case of new object initialization in super admin
# It was throwing error as the default value of column '{}' was failing in deserialization.
return {}.with_indifferent_access if new_record? && @attributes['serialized_value']&.value_before_type_cast == '{}'
serialized_value[:value]
end
-1
View File
@@ -349,7 +349,6 @@ 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
View File
@@ -109,8 +109,8 @@ class User < ApplicationRecord
self.email = email.try(:downcase)
end
def send_devise_notification(notification, *args)
devise_mailer.with(account: Current.account).send(notification, self, *args).deliver_later
def send_devise_notification(notification, *)
devise_mailer.with(account: Current.account).send(notification, self, *).deliver_later
end
def set_password_and_uid
@@ -31,7 +31,11 @@ class Crm::Leadsquared::LeadFinderService
def find_by_phone_number(contact)
return if contact.phone_number.blank?
search_by_field(contact.phone_number)
lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact)
return if lead_data.blank? || lead_data['Mobile'].nil?
search_by_field(lead_data['Mobile'])
end
def search_by_field(value)
@@ -20,11 +20,29 @@ class Crm::Leadsquared::Mappers::ContactMapper
'FirstName' => contact.name.presence,
'LastName' => contact.last_name.presence,
'EmailAddress' => contact.email.presence,
'Mobile' => contact.phone_number.presence,
'Mobile' => formatted_phone_number,
'Source' => brand_name
}.compact
end
def formatted_phone_number
# it seems like leadsquared needs a different phone number format
# it's not documented anywhere, so don't bother trying to look up online
# After some trial and error, I figured out the format, its +<country_code>-<national_number>
return nil if contact.phone_number.blank?
parsed = TelephoneNumber.parse(contact.phone_number)
return contact.phone_number unless parsed.valid?
country_code = parsed.country.country_code
e164 = parsed.e164_number
e164 = e164.sub(/^\+/, '')
national_number = e164.sub(/^#{Regexp.escape(country_code)}/, '')
"+#{country_code}-#{national_number}"
end
def brand_name
::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot'
end
@@ -2,17 +2,9 @@ class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
if conversation.campaign.present?
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not triggering templates because conversation has a campaign" }
return
end
return if conversation.campaign.present?
return if conversation.last_incoming_message.blank?
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
@@ -30,41 +22,14 @@ class MessageTemplates::HookExecutionService
def should_send_out_of_office_message?
# should not send if its a tweet message
if conversation.tweet?
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because it's a tweet conversation" }
return false
end
return false if conversation.tweet?
# should not send for outbound messages
unless message.incoming?
Rails.logger.debug { "[OutOfOffice][#{conversation.id}] Not sending out-of-office message because the message is outgoing" }
return false
end
return false unless message.incoming?
# 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
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
return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago])
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
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
end
def first_message_from_contact?
@@ -12,7 +12,6 @@ class MessageTemplates::Template::CsatSurvey
private
delegate :contact, :account, :inbox, to: :conversation
delegate :csat_config, to: :inbox
def should_send_csat_survey?
return true unless survey_rules_configured?
@@ -6,7 +6,6 @@ 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,14 +137,19 @@ class Twilio::IncomingMessageService
end
def download_with_auth(media_url)
Down.download(
media_url,
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
auth_credentials = if twilio_channel.api_key_sid.present?
# When using api_key_sid, the auth token should be the api_secret_key
[twilio_channel.api_key_sid, twilio_channel.auth_token]
else
# When using account_sid, the auth token is the account's auth token
[twilio_channel.account_sid, twilio_channel.auth_token]
end
Down.download(media_url, http_basic_authentication: auth_credentials)
end
def handle_download_attachment_error(error, media_url)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying"
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
Down.download(media_url)
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
+2 -2
View File
@@ -18,7 +18,7 @@ By default, it renders:
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name= "turbolinks-cache-control" content= "no-cache">
<meta name= "turbo-cache-control" content= "no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<%= vite_client_tag %>
@@ -39,7 +39,7 @@ By default, it renders:
<% else %>
<title><%= @portal.page_title%></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
@@ -0,0 +1,9 @@
json.access_token @resource.access_token.token
json.expiry nil
json.user do
json.id @resource.id
json.name @resource.name
json.display_name @resource.display_name
json.email @resource.email
json.pubsub_token @resource.pubsub_token
end
@@ -0,0 +1,184 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<!-- Search form for the results page -->
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 py-4">
<div class="flex items-center flex-row">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:underline hover:cursor-pointer leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<!-- Search form for the results page -->
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
</div>
<% end %>
<script>
document.addEventListener('turbo:load', function() {
const searchInputs = document.querySelectorAll('[data-search-input]');
searchInputs.forEach(function(input) {
let debounceTimer;
// Move cursor to end of input text after autofocus
if (input.value.length > 0) {
setTimeout(function() {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
input.addEventListener('input', function() {
const form = input.closest('[data-search-form]');
const query = input.value.trim();
// Clear existing timer
clearTimeout(debounceTimer);
// Only search if there's a query and it's different from current
if (query.length > 0) {
debounceTimer = setTimeout(function() {
// Check if the query has actually changed
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
if (query !== currentQuery) {
// Use Turbo.visit for SPA-like navigation
const formData = new FormData(form);
const searchParams = new URLSearchParams(formData);
const url = form.action + '?' + searchParams.toString();
Turbo.visit(url);
}
}, 500); // 500ms debounce
}
});
// Handle manual form submission
input.closest('[data-search-form]').addEventListener('submit', function(e) {
clearTimeout(debounceTimer);
});
});
});
</script>
<div class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.size) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400"></span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<!-- Pagination -->
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(page: @articles.prev_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(page: @articles.next_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</div>
+4
View File
@@ -1,3 +1,7 @@
# TODO: Phase out the custom ConnectionPool wrappers ($alfred / $velma),
# switch to plain Redis clients here and let Rails 7.1+ handle pooling
# via `pool:` in RedisCacheStore (see rack_attack initializer).
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
+4
View File
@@ -14,6 +14,10 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) || Rails.env.development?
resource '*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
end
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_API_CORS', false))
resource '/api/*', headers: :any, methods: :any, expose: %w[access-token client uid expiry]
end
end
end
+6 -1
View File
@@ -11,7 +11,12 @@ class Rack::Attack
# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
# https://github.com/rack/rack-attack/issues/102
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma)
# Rails 7.1 automatically adds its own ConnectionPool around RedisCacheStore.
# Because `$velma` is *already* a ConnectionPool, double-wrapping causes
# Redis calls like `get` to hit the outer wrapper and explode.
# `pool: false` tells Rails to skip its internal pool and use ours directly.
# TODO: We can use build in connection pool in future upgrade
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
class Request < ::Rack::Request
# You many need to specify a method to fetch the correct remote IP address
+7
View File
@@ -254,6 +254,13 @@ en:
empty_placeholder: No results found.
loading_placeholder: Searching...
results_title: Search results
results: Search Results
results_for: "Search Results for '%{query}'"
no_results: "No results found for '%{query}'"
found_results:
one: Found 1 result
other: "Found %{count} results"
submit: Search
toc_header: 'On this page'
hero:
sub_title: Search for the articles here or browse the categories below.
+7
View File
@@ -240,6 +240,13 @@ es:
empty_placeholder: No se encontraron resultados.
loading_placeholder: Buscando...
results_title: Buscar resultados
results: Resultados de búsqueda
results_for: "Resultados de búsqueda para '%{query}'"
no_results: "No se encontraron resultados para '%{query}'"
found_results:
one: Se encontró 1 resultado
other: "Se encontraron %{count} resultados"
submit: Buscar
toc_header: 'En esta página'
hero:
sub_title: Busque aquí los artículos o busque las categorías de abajo.
+4 -2
View File
@@ -60,8 +60,8 @@ Rails.application.routes.draw do
end
resources :assistant_responses
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index] do
resources :copilot_messages, only: [:index]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy]
end
@@ -396,6 +396,7 @@ Rails.application.routes.draw do
resources :users, only: [:create, :show, :update, :destroy] do
member do
get :login
post :token
end
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@@ -441,6 +442,7 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
@@ -0,0 +1,8 @@
class RemoveUuidFromCopilotThreads < ActiveRecord::Migration[7.1]
def change
remove_column :copilot_threads, :uuid, :string
add_column :copilot_threads, :assistant_id, :integer
add_index :copilot_threads, :assistant_id
end
end
@@ -0,0 +1,5 @@
class RemoveUserIdFromCopilotMessages < ActiveRecord::Migration[7.1]
def change
remove_reference :copilot_messages, :user, index: true
end
end
@@ -0,0 +1,11 @@
class ChangeMessageTypeToIntegerInCopilotMessages < ActiveRecord::Migration[7.1]
def up
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :integer, default: 0
end
def down
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :string, default: 'user'
end
end
+4 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -577,27 +577,25 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
create_table "copilot_messages", force: :cascade do |t|
t.bigint "copilot_thread_id", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.string "message_type", null: false
t.jsonb "message", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "message_type", default: 0
t.index ["account_id"], name: "index_copilot_messages_on_account_id"
t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
t.index ["user_id"], name: "index_copilot_messages_on_user_id"
end
create_table "copilot_threads", force: :cascade do |t|
t.string "title", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "assistant_id"
t.index ["account_id"], name: "index_copilot_threads_on_account_id"
t.index ["assistant_id"], name: "index_copilot_threads_on_assistant_id"
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
end
create_table "csat_survey_responses", force: :cascade do |t|
+3 -3
View File
@@ -16,10 +16,10 @@ KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
Environment="RAILS_ENV=production"
Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
+3 -3
View File
@@ -21,10 +21,10 @@ MemoryHigh=1.4G
MemorySwapMax=0
OOMPolicy=stop
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3/bin:/home/chatwoot/.rvm/gems/ruby-3.3.3@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.3.3/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
Environment="RAILS_ENV=production"
Environment="NODE_ENV=production"
Environment="RAILS_LOG_TO_STDOUT=true"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.3.3"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.3.3:/home/chatwoot/.rvm/gems/ruby-3.3.3@global"
Environment="GEM_HOME=/home/chatwoot/.rvm/gems/ruby-3.4.4"
Environment="GEM_PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4:/home/chatwoot/.rvm/gems/ruby-3.4.4@global"
+2 -2
View File
@@ -338,8 +338,8 @@ function setup_chatwoot() {
sudo -i -u chatwoot << EOF
rvm --version
rvm autolibs disable
rvm install "ruby-3.3.3"
rvm use 3.3.3 --default
rvm install "ruby-3.4.4"
rvm use 3.4.4 --default
git clone https://github.com/chatwoot/chatwoot.git
cd chatwoot
+28
View File
@@ -0,0 +1,28 @@
## 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`
+100
View File
@@ -0,0 +1,100 @@
{
"$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.

After

Width:  |  Height:  |  Size: 2.7 KiB

+49
View File
@@ -0,0 +1,49 @@
---
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.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+4 -4
View File
@@ -1,6 +1,6 @@
# pre-build stage
FROM node:23-alpine as node
FROM ruby:3.3.3-alpine3.19 AS pre-builder
FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
@@ -90,13 +90,13 @@ RUN if [ "$RAILS_ENV" = "production" ]; then \
RUN git rev-parse HEAD > /app/.git_sha
# Remove unnecessary files
RUN rm -rf /gems/ruby/3.3.0/cache/*.gem \
&& find /gems/ruby/3.3.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
&& find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
&& rm -rf .git \
&& rm .gitignore
# final build stage
FROM ruby:3.3.3-alpine3.19
FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="23.7.0"
ARG PNPM_VERSION="10.2.0"
@@ -47,7 +47,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
config: [
:product_name, :feature_faq, :feature_memory,
:welcome_message, :handoff_message, :resolution_message,
:instructions
:instructions, :temperature
])
end
@@ -1,21 +1,28 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message_type: :user
)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
uuid: params[:copilot_thread_id], user_id: Current.user.id
id: params[:copilot_thread_id],
user: Current.user
)
end
@@ -1,18 +1,41 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message],
user: Current.user,
assistant: assistant
)
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
end
end
private
def ensure_message
return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:message, :assistant_id)
end
def permitted_params
params.permit(:page)
end
@@ -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
@@ -7,7 +7,8 @@ module Captain::ChatHelper
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' }
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
@@ -50,7 +50,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def message_content(message)
return message.content if message.content.present?
'User has shared an attachment' if message.attachments.any?
return 'User has shared an attachment' if message.attachments.any?
'User has shared a message without content'
end
@@ -1,7 +1,10 @@
class CaptainListener < BaseListener
include ::Events::Types
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
assistant = conversation.inbox.captain_assistant
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
@@ -0,0 +1,11 @@
module Enterprise::ActionCableListener
include Events::Types
def copilot_message_created(event)
copilot_message = event.data[:copilot_message]
copilot_thread = copilot_message.copilot_thread
account = copilot_thread.account
user = copilot_thread.user
broadcast(account, [user.pubsub_token], COPILOT_MESSAGE_CREATED, copilot_message.push_event_data)
end
end
@@ -29,6 +29,7 @@ class Captain::Assistant < ApplicationRecord
has_many :inboxes,
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
validates :name, presence: true
validates :description, presence: true
+28 -5
View File
@@ -4,24 +4,47 @@
#
# id :bigint not null, primary key
# message :jsonb not null
# message_type :string not null
# message_type :integer default("user")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# copilot_thread_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_copilot_messages_on_account_id (account_id)
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
# index_copilot_messages_on_user_id (user_id)
#
class CopilotMessage < ApplicationRecord
belongs_to :copilot_thread
belongs_to :user
belongs_to :account
validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
before_validation :ensure_account
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true
after_create_commit :broadcast_message
def push_event_data
{
id: id,
message: message,
message_type: message_type,
created_at: created_at.to_i,
copilot_thread: copilot_thread.push_event_data
}
end
private
def ensure_account
self.account = copilot_thread.account
end
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
end
+34 -12
View File
@@ -2,25 +2,47 @@
#
# Table name: copilot_threads
#
# id :bigint not null, primary key
# title :string not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
# id :bigint not null, primary key
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :integer
# user_id :bigint not null
#
# Indexes
#
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_user_id (user_id)
# index_copilot_threads_on_uuid (uuid) UNIQUE
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_assistant_id (assistant_id)
# index_copilot_threads_on_user_id (user_id)
#
class CopilotThread < ApplicationRecord
belongs_to :user
belongs_to :account
has_many :copilot_messages, dependent: :destroy
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :copilot_messages, dependent: :destroy_async
validates :title, presence: true
validates :uuid, presence: true, uniqueness: true
def push_event_data
{
id: id,
title: title,
created_at: created_at.to_i,
user: user.push_event_data,
account_id: account_id
}
end
def previous_history
copilot_messages
.where(message_type: %w[user assistant])
.order(created_at: :asc)
.map do |copilot_message|
{
content: copilot_message.message,
role: copilot_message.message_type
}
end
end
end
@@ -1,23 +1,24 @@
class Captain::ToolRegistryService
attr_reader :registered_tools, :tools
def initialize(assistant)
def initialize(assistant, user: nil)
@assistant = assistant
@user = user
@registered_tools = []
@tools = {}
end
def register_tool(tool_class)
tool = tool_class.new(@assistant)
tool = tool_class.new(@assistant, user: @user)
return unless tool.active?
@tools[tool.name] = tool
@registered_tools << tool.to_registry_format
end
def method_missing(method_name, *arguments)
def method_missing(method_name, *)
if @tools.key?(method_name.to_s)
@tools[method_name.to_s].execute(*arguments)
@tools[method_name.to_s].execute(*)
else
super
end
@@ -36,4 +36,18 @@ 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
@@ -0,0 +1,39 @@
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService
def name
'get_article'
end
def description
'Get details of an article including its content and metadata'
end
def parameters
{
type: 'object',
properties: {
article_id: {
type: 'number',
description: 'The ID of the article to retrieve'
}
},
required: %w[article_id]
}
end
def execute(arguments)
article_id = arguments['article_id']
Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" }
return 'Missing required parameters' if article_id.blank?
article = Article.find_by(id: article_id, account_id: @assistant.account_id)
return 'Article not found' if article.nil?
article.to_llm_text
end
def active?
user_has_permission('knowledge_base_manage')
end
end
@@ -0,0 +1,39 @@
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService
def name
'get_contact'
end
def description
'Get details of a contact including their profile information'
end
def parameters
{
type: 'object',
properties: {
contact_id: {
type: 'number',
description: 'The ID of the contact to retrieve'
}
},
required: %w[contact_id]
}
end
def execute(arguments)
contact_id = arguments['contact_id']
Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}"
return 'Missing required parameters' if contact_id.blank?
contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id)
return 'Contact not found' if contact.nil?
contact.to_llm_text
end
def active?
user_has_permission('contact_manage')
end
end
@@ -0,0 +1,41 @@
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService
def name
'get_conversation'
end
def description
'Get details of a conversation including messages and contact information'
end
def parameters
{
type: 'object',
properties: {
conversation_id: {
type: 'number',
description: 'The ID of the conversation to retrieve'
}
},
required: %w[conversation_id]
}
end
def execute(arguments)
conversation_id = arguments['conversation_id']
Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}"
return 'Missing required parameters' if conversation_id.blank?
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
return 'Conversation not found' if conversation.blank?
conversation.to_llm_text
end
def active?
user_has_permission('conversation_manage') ||
user_has_permission('conversation_unassigned_manage') ||
user_has_permission('conversation_participating_manage')
end
end
@@ -0,0 +1,71 @@
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService
def name
'search_articles'
end
def description
'Search articles based on parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: ['query']
}
end
def execute(arguments)
query = arguments['query']
category_id = arguments['category_id']
status = arguments['status']
Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}"
return 'Missing required parameters' if query.blank?
articles = fetch_articles(query, category_id, status)
return 'No articles found' unless articles.exists?
total_count = articles.count
articles = articles.limit(100)
<<~RESPONSE
#{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
#{articles.map(&:to_llm_text).join("\n---\n")}
RESPONSE
end
def active?
user_has_permission('knowledge_base_manage')
end
private
def fetch_articles(query, category_id, status)
articles = Article.where(account_id: @assistant.account_id)
articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
articles = articles.where(category_id: category_id) if category_id.present?
articles = articles.where(status: status) if status.present?
articles
end
def properties
{
query: {
type: 'string',
description: 'Search articles by title or content (partial match)'
},
category_id: {
type: 'number',
description: 'Filter articles by category ID'
},
status: {
type: 'string',
enum: %w[draft published archived],
description: 'Filter articles by status'
}
}
end
end
@@ -0,0 +1,61 @@
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService
def name
'search_contacts'
end
def description
'Search contacts based on query parameters'
end
def parameters
{
type: 'object',
properties: properties,
required: []
}
end
def execute(arguments)
email = arguments['email']
phone_number = arguments['phone_number']
name = arguments['name']
Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}"
contacts = Contact.where(account_id: @assistant.account_id)
contacts = contacts.where(email: email) if email.present?
contacts = contacts.where(phone_number: phone_number) if phone_number.present?
contacts = contacts.where('LOWER(name) ILIKE ?', "%#{name.downcase}%") if name.present?
return 'No contacts found' unless contacts.exists?
contacts = contacts.limit(100)
<<~RESPONSE
#{contacts.map(&:to_llm_text).join("\n---\n")}
RESPONSE
end
def active?
user_has_permission('contact_manage')
end
private
def properties
{
email: {
type: 'string',
description: 'Filter contacts by email'
},
phone_number: {
type: 'string',
description: 'Filter contacts by phone number'
},
name: {
type: 'string',
description: 'Filter contacts by name (partial match)'
}
}
end
end
@@ -33,6 +33,12 @@ 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)
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: @copilot_message
@@ -1,8 +1,5 @@
json.payload do
json.array! @copilot_messages do |message|
json.id message.id
json.message message.message
json.message_type message.message_type
json.created_at message.created_at.to_i
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: message
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_thread', formats: [:json], resource: @copilot_thread
@@ -1,12 +1,5 @@
json.payload do
json.array! @copilot_threads do |thread|
json.id thread.id
json.title thread.title
json.uuid thread.uuid
json.created_at thread.created_at.to_i
json.user do
json.id thread.user.id
json.name thread.user.name
end
json.partial! 'api/v1/models/captain/copilot_thread', resource: thread
end
end
@@ -0,0 +1,6 @@
json.id resource.id
json.message resource.message
json.message_type resource.message_type
json.created_at resource.created_at.to_i
json.copilot_thread resource.copilot_thread.push_event_data
json.account_id resource.account_id
@@ -0,0 +1,6 @@
json.id resource.id
json.title resource.title
json.created_at resource.created_at.to_i
json.user resource.user.push_event_data
json.assistant resource.assistant.push_event_data
json.account_id resource.account_id
+13 -48
View File
@@ -6,6 +6,7 @@ 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
@@ -50,7 +51,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
VIMEO_REGEX => :make_vimeo_embed,
MP4_REGEX => :make_video_embed,
LOOM_REGEX => :make_loom_embed,
ARCADE_REGEX => :make_arcade_embed
ARCADE_REGEX => :make_arcade_embed,
WISTIA_REGEX => :make_wistia_embed
}
embedding_methods.each do |regex, method|
@@ -76,67 +78,30 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
def make_youtube_embed(youtube_match)
video_id = youtube_match[1]
%(
<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>
)
EmbedRenderer.youtube(video_id)
end
def make_loom_embed(loom_match)
video_id = loom_match[1]
%(
<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>
)
EmbedRenderer.loom(video_id)
end
def make_vimeo_embed(vimeo_match)
video_id = vimeo_match[1]
%(
<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>
)
EmbedRenderer.vimeo(video_id)
end
def make_video_embed(link_url)
%(
<video width="640" height="360" controls>
<source src="#{link_url}" type="video/mp4">
Your browser does not support the video tag.
</video>
)
EmbedRenderer.video(link_url)
end
def make_wistia_embed(wistia_match)
video_id = wistia_match[2]
EmbedRenderer.wistia(video_id)
end
def make_arcade_embed(arcade_match)
video_id = arcade_match[1]
%(
<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>
)
EmbedRenderer.arcade(video_id)
end
end
+87
View File
@@ -0,0 +1,87 @@
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
+3
View File
@@ -54,4 +54,7 @@ module Events::Types
# agent events
AGENT_ADDED = 'agent.added'
AGENT_REMOVED = 'agent.removed'
# copilot events
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
end
+144 -10
View File
@@ -1,22 +1,156 @@
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'
require 'json_refs'
require 'fileutils'
require 'pathname'
require 'yaml'
require 'json'
base_path = Rails.root.join('swagger')
Dir.chdir(base_path) do
swagger_index = YAML.safe_load(File.open('index.yml'))
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)
final_build = JsonRefs.call(
swagger_index,
resolve_local_ref: false,
resolve_file_ref: true,
resolve_file_ref: true, # Uses CWD (swagger_dir) for resolving file refs
logging: true
)
File.write('swagger.json', JSON.pretty_generate(final_build))
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)
puts 'Swagger build was successful.'
puts "Generated #{base_path}/swagger.json"
puts "Generated #{absolute_swagger_json_path}"
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
+1 -1
View File
@@ -39,6 +39,7 @@
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@hotwired/turbo-rails": "^8.0.13",
"@iconify-json/material-symbols": "^1.2.10",
"@june-so/analytics-next": "^2.0.0",
"@lk77/vue3-color": "^3.0.6",
@@ -84,7 +85,6 @@
"snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2",
"tinykeys": "^3.0.0",
"turbolinks": "^5.2.0",
"urlpattern-polyfill": "^10.0.0",
"video.js": "7.18.1",
"videojs-record": "4.5.0",
+22 -8
View File
@@ -37,6 +37,9 @@ importers:
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
'@hotwired/turbo-rails':
specifier: ^8.0.13
version: 8.0.13
'@iconify-json/material-symbols':
specifier: ^1.2.10
version: 1.2.10
@@ -172,9 +175,6 @@ importers:
tinykeys:
specifier: ^3.0.0
version: 3.0.0
turbolinks:
specifier: ^5.2.0
version: 5.2.0
urlpattern-polyfill:
specifier: ^10.0.0
version: 10.0.0
@@ -865,6 +865,13 @@ packages:
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
'@hotwired/turbo-rails@8.0.13':
resolution: {integrity: sha512-6SCnnOSzhtaJ0pNkAjncZxjtKsK3sP/vPEkCnTXBXSHkr+vF7DTZkOlwjhms1DbbQNTsjCsBoKvzSMbh/omSCQ==}
'@hotwired/turbo@8.0.13':
resolution: {integrity: sha512-M7qXUqcGab6G5PKOiwhgbByTtrPgKPFCTMNQ52QhzUEXEqmp0/ApEguUesh/FPiUjrmFec+3lq98KsWnYY2C7g==}
engines: {node: '>= 14'}
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -1036,6 +1043,9 @@ packages:
'@rails/actioncable@6.1.3':
resolution: {integrity: sha512-m02524MR9cTnUNfGz39Lkx9jVvuL0tle4O7YgvouJ7H83FILxzG1nQ5jw8pAjLAr9XQGu+P1sY4SKE3zyhCNjw==}
'@rails/actioncable@7.2.201':
resolution: {integrity: sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw==}
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
@@ -4710,9 +4720,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
turbolinks@5.2.0:
resolution: {integrity: sha512-pMiez3tyBo6uRHFNNZoYMmrES/IaGgMhQQM+VFF36keryjb5ms0XkVpmKHkfW/4Vy96qiGW3K9bz0tF5sK9bBw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -5723,6 +5730,13 @@ snapshots:
'@histoire/vendors@0.17.17': {}
'@hotwired/turbo-rails@8.0.13':
dependencies:
'@hotwired/turbo': 8.0.13
'@rails/actioncable': 7.2.201
'@hotwired/turbo@8.0.13': {}
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -5942,6 +5956,8 @@ snapshots:
'@rails/actioncable@6.1.3': {}
'@rails/actioncable@7.2.201': {}
'@rails/ujs@7.1.400': {}
'@rollup/rollup-android-arm-eabi@4.40.2':
@@ -10200,8 +10216,6 @@ snapshots:
tslib@2.8.1: {}
turbolinks@5.2.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -79,6 +79,18 @@ describe Messages::Instagram::MessageBuilder do
expect(instagram_inbox.messages.count).to be 1
end
it 'discards duplicate messages from webhook events with the same message_id' do
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
initial_message_count = instagram_inbox.messages.count
expect(initial_message_count).to be 1
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.messages.count).to eq initial_message_count
end
it 'creates message for shared reel' do
messaging = shared_reel_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
@@ -151,11 +163,15 @@ describe Messages::Instagram::MessageBuilder do
end
it 'does not create message for unsupported file type' do
conversation
# try to create a message with unsupported file type
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
# Conversation should exist but no new message should be created
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 0
end
@@ -189,19 +189,22 @@ describe Messages::Instagram::Messenger::MessageBuilder do
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
conversation
# create a message with unsupported file type
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
contact_inbox
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created but message and attachments won't be created
# Conversation should exist but no new message should be created
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 0
contact = instagram_messenger_channel.inbox.contacts.first
expect(contact.name).to eq('Jane Dae')
end
end
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end
describe '#timeseries' do
include_examples 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
end
describe '#aggregate_value' do
include_examples 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
end
end
@@ -76,6 +76,54 @@ RSpec.describe 'Platform Users API', type: :request do
end
end
describe 'POST /platform/api/v1/users/{user_id}/token' do
context 'when it is an unauthenticated platform app' do
it 'returns unauthorized' do
post "/platform/api/v1/users/#{user.id}/token"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an invalid platform app token' do
it 'returns unauthorized' do
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: 'invalid' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated platform app' do
let(:platform_app) { create(:platform_app) }
it 'returns unauthorized when its not a permissible object' do
post "/platform/api/v1/users/#{user.id}/token", headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns access token for the user with expiry and user info' do
create(:platform_app_permissible, platform_app: platform_app, permissible: user)
post "/platform/api/v1/users/#{user.id}/token",
headers: { api_access_token: platform_app.access_token.token }, as: :json
expect(response).to have_http_status(:success)
data = response.parsed_body
# Check access token and expiry
expect(data['access_token']).to eq(user.access_token.token)
expect(data['expiry']).to be_nil
# Check user info
expect(data['user']).to include(
'id' => user.id,
'name' => user.name,
'display_name' => user.display_name,
'email' => user.email,
'pubsub_token' => user.pubsub_token
)
end
end
end
describe 'POST /platform/api/v1/users/' do
context 'when it is an unauthenticated platform app' do
it 'returns unauthorized' do
@@ -4,12 +4,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -20,9 +20,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
context 'when thread uuid is invalid' do
context 'when thread id is invalid' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -30,4 +30,49 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'creates a new message' do
message_content = { 'content' => 'This is a test message' }
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
params: { message: message_content },
headers: user.create_new_auth_token,
as: :json
end.to change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
expect(CopilotMessage.last.message).to eq(message_content)
expect(CopilotMessage.last.message_type).to eq('user')
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
end
end
context 'when thread does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when thread belongs to another user' do
let(:another_user) { create(:user, account: account) }
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -18,7 +18,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
context 'when it is an agent' do
context 'when it is an authenticated user' do
it 'fetches copilot threads for the current user' do
# Create threads for the current agent
create_list(:captain_copilot_thread, 3, account: account, user: agent)
@@ -47,4 +47,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with invalid params' do
it 'returns error when message is blank' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: '', assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:error]).to eq('Message is required')
end
it 'returns error when assistant_id is invalid' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: 'Hello', assistant_id: 0 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'with valid params' do
it 'creates a new copilot thread with initial message' do
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(CopilotThread, :count).by(1)
.and change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
thread = CopilotThread.last
expect(thread.title).to eq(valid_params[:message])
expect(thread.user_id).to eq(agent.id)
expect(thread.assistant_id).to eq(assistant.id)
message = thread.copilot_messages.last
expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message])
end
end
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
describe ActionCableListener do
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
let(:listener) { described_class.instance }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data.merge(account_id: account.id)
)
listener.copilot_message_created(event)
end
end
end
@@ -0,0 +1,57 @@
require 'rails_helper'
describe CaptainListener do
let(:listener) { described_class.instance }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when feature_memory is enabled' do
before do
assistant.config['feature_memory'] = true
assistant.config['feature_faq'] = false
assistant.save!
end
it 'generates and updates notes' do
expect(Captain::Llm::ContactNotesService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
context 'when feature_faq is enabled' do
before do
assistant.config['feature_faq'] = true
assistant.config['feature_memory'] = false
assistant.save!
end
it 'generates and deduplicates FAQs' do
expect(Captain::Llm::ConversationFaqService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
end
end
@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe CopilotMessage, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:copilot_thread) }
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:message_type) }
it { is_expected.to validate_presence_of(:message) }
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
describe '#ensure_account' do
it 'sets the account from the copilot thread before validation' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
message.valid?
expect(message.account).to eq(copilot_thread.account)
end
end
describe '#broadcast_message' do
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
message.save!
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:message_content) { { 'content' => 'Test message' } }
let(:copilot_message) do
create(:captain_copilot_message,
copilot_thread: copilot_thread,
message_type: 'user',
message: message_content)
end
it 'returns the correct event data' do
event_data = copilot_message.push_event_data
expect(event_data[:id]).to eq(copilot_message.id)
expect(event_data[:message]).to eq(message_content)
expect(event_data[:message_type]).to eq('user')
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
end
end
end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe CopilotThread, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
it 'returns the correct event data' do
event_data = copilot_thread.push_event_data
expect(event_data[:id]).to eq(copilot_thread.id)
expect(event_data[:title]).to eq('Test Thread')
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
expect(event_data[:user]).to eq(user.push_event_data)
expect(event_data[:account_id]).to eq(account.id)
end
end
describe '#previous_history' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
context 'when there are messages in the thread' do
before do
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
end
it 'returns only user and assistant messages in chronological order' do
history = copilot_thread.previous_history
expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user')
expect(history[0][:content]).to eq({ 'content' => 'User message' })
expect(history[1][:role]).to eq('assistant')
expect(history[1][:content]).to eq({ 'content' => 'Assistant message' })
end
end
context 'when there are no messages in the thread' do
it 'returns an empty array' do
expect(copilot_thread.previous_history).to eq([])
end
end
end
end
@@ -4,7 +4,7 @@ require 'rails_helper'
class TestTool < Captain::Tools::BaseService
attr_accessor :tool_active
def initialize(*args)
def initialize(assistant, user: nil)
super
@tool_active = true
end

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