From d7c0507e33496942ba70c97110bbe21de85f33c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 17:35:12 -0800 Subject: [PATCH 001/578] chore(deps): Bump net-imap from 0.4.17 to 0.4.19 (#10871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [net-imap](https://github.com/ruby/net-imap) from 0.4.17 to 0.4.19.
Release notes

Sourced from net-imap's releases.

v0.4.19

What's Changed

🔒 Security Fix

Fixes CVE-2025-25186 (GHSA-7fc5-f82f-cx69): A malicious server can exhaust client memory by sending APPENDUID or COPYUID responses with very large uid-set ranges. Net::IMAP::UIDPlusData expands these ranges into arrays of integers.

Fix with minor API changes

Set config.parser_use_deprecated_uidplus_data to false to replace UIDPlusData with AppendUIDData and CopyUIDData. These classes store their UIDs as Net::IMAP::SequenceSet objects (not expanded into arrays of integers). Code that does not handle APPENDUID or COPYUID responses should not see any difference. Code that does handle these responses may need to be updated.

For v0.3.8, this option is not available For v0.4.19, the default value is true. For v0.5.6, the default value is :up_to_max_size. For v0.6.0, the only allowed value will be false (UIDPlusData will be removed from v0.6).

Mitigate with backward compatible API

Adjust config.parser_max_deprecated_uidplus_data_size to limit the maximum UIDPlusData UID set size. When config.parser_use_deprecated_uidplus_data == true, larger sets will crash. When config.parser_use_deprecated_uidplus_data == :up_to_max_size, larger sets will use AppendUIDData or CopyUIDData.

For v0.3,8, this limit is hard-coded to 10,000. For v0.4.19, this limit defaults to 1000. For v0.5.6, this limit defaults to 100. For v0.6.0, the only allowed value will be 0 (UIDPlusData will be removed from v0.6).

Please Note: unhandled responses

If the client does not add response handlers to prune unhandled responses, a malicious server can still eventually exhaust all client memory, by repeatedly sending malicious responses. However, net-imap has always retained unhandled responses, and it has always been necessary for long-lived connections to prune these responses. This is not significantly different from connecting to a trusted server with a long-lived connection. To limit the maximum number of retained responses, a simple handler might look something like the following:

limit = 1000
imap.add_response_handler do |resp|
  next unless resp.respond_to?(:name) && resp.respond_to?(:data)
  name = resp.name
code = resp.data.code&.name if
resp.data.in?(Net::IMAP::ResponseText)
  imap.responses(name) { _1.slice!(0...-limit) }
  imap.responses(code) { _1.slice!(0...-limit) }
end

Added

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=net-imap&package-manager=bundler&previous-version=0.4.17&new-version=0.4.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/chatwoot/chatwoot/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 45c54bd46..fa45d1e89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,7 +186,7 @@ GEM database_cleaner-core (2.0.1) datadog-ci (0.8.3) msgpack - date (3.3.4) + date (3.4.1) ddtrace (1.23.2) datadog-ci (~> 0.8.1) debase-ruby_core_source (= 3.3.1) @@ -487,7 +487,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.17) + net-imap (0.4.19) date net-protocol net-pop (0.1.2) @@ -782,7 +782,7 @@ GEM time_diff (0.3.0) activesupport i18n - timeout (0.4.1) + timeout (0.4.3) trailblazer-option (0.1.2) twilio-ruby (5.77.0) faraday (>= 0.9, < 3.0) From 02000de90523fb43bb9c33699c073e14309fd16f Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 10 Feb 2025 19:33:26 -0800 Subject: [PATCH 002/578] chore: Add updated_at attribute to the conversation event (#10873) This PR adds updated_at attribute to the conversation event. --- app/presenters/conversations/event_data_presenter.rb | 3 ++- .../api/v1/conversations/partials/_conversation.json.jbuilder | 1 + spec/models/conversation_spec.rb | 1 + spec/presenters/conversations/event_data_presenter_spec.rb | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index 2617721ec..1ba188de8 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -42,7 +42,8 @@ class Conversations::EventDataPresenter < SimpleDelegator contact_last_seen_at: contact_last_seen_at.to_i, last_activity_at: last_activity_at.to_i, timestamp: last_activity_at.to_i, - created_at: created_at.to_i + created_at: created_at.to_i, + updated_at: updated_at.to_i } end end diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index a9a580e26..2e6474953 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -44,6 +44,7 @@ json.muted conversation.muted? json.snoozed_until conversation.snoozed_until json.status conversation.status json.created_at conversation.created_at.to_i +json.updated_at conversation.updated_at.to_i json.timestamp conversation.last_activity_at.to_i json.first_reply_created_at conversation.first_reply_created_at.to_i json.unread_count conversation.unread_incoming_messages.count diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 5813d8c3c..0c8f9de50 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -538,6 +538,7 @@ RSpec.describe Conversation do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, + updated_at: conversation.updated_at.to_i, waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index f6a400b96..6f2534efb 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -31,6 +31,7 @@ RSpec.describe Conversations::EventDataPresenter do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, + updated_at: conversation.updated_at.to_i, waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 From 8faccba052e9ebb0cc33acb5ba2626373866720d Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 10 Feb 2025 20:22:11 -0800 Subject: [PATCH 003/578] chore: Update the precision of the updated_at timestamp in conversation model (#10875) Use to_f instead of to_i to preserve the millisecond precision in the UI. --- app/presenters/conversations/event_data_presenter.rb | 2 +- .../api/v1/conversations/partials/_conversation.json.jbuilder | 2 +- spec/models/conversation_spec.rb | 2 +- spec/presenters/conversations/event_data_presenter_spec.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index 1ba188de8..2ef69080d 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -43,7 +43,7 @@ class Conversations::EventDataPresenter < SimpleDelegator last_activity_at: last_activity_at.to_i, timestamp: last_activity_at.to_i, created_at: created_at.to_i, - updated_at: updated_at.to_i + updated_at: updated_at.to_f } end end diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 2e6474953..8867ba695 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -44,7 +44,7 @@ json.muted conversation.muted? json.snoozed_until conversation.snoozed_until json.status conversation.status json.created_at conversation.created_at.to_i -json.updated_at conversation.updated_at.to_i +json.updated_at conversation.updated_at.to_f json.timestamp conversation.last_activity_at.to_i json.first_reply_created_at conversation.first_reply_created_at.to_i json.unread_count conversation.unread_incoming_messages.count diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 0c8f9de50..1938fb204 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -538,7 +538,7 @@ RSpec.describe Conversation do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, - updated_at: conversation.updated_at.to_i, + updated_at: conversation.updated_at.to_f, waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index 6f2534efb..a645caf1d 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Conversations::EventDataPresenter do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, - updated_at: conversation.updated_at.to_i, + updated_at: conversation.updated_at.to_f, waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 From 3c78d25306e0c7e307d8740ad85ff2eae971ce4f Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 10 Feb 2025 23:16:15 -0800 Subject: [PATCH 004/578] chore: Reload conversation data in ActionCableBroadcastJob before sending (#10876) During high-traffic periods, events may appear out of order, causing the conversation job to queue outdated data, which can lead to issues in the UI. This update ensures that only the latest available data is sent to the UI. The conversation object is refreshed before sending it to the UI. --- app/jobs/action_cable_broadcast_job.rb | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/jobs/action_cable_broadcast_job.rb b/app/jobs/action_cable_broadcast_job.rb index b1208a97c..ce1230029 100644 --- a/app/jobs/action_cable_broadcast_job.rb +++ b/app/jobs/action_cable_broadcast_job.rb @@ -2,8 +2,34 @@ class ActionCableBroadcastJob < ApplicationJob queue_as :critical def perform(members, event_name, data) + return if members.blank? + + broadcast_data = prepare_broadcast_data(event_name, data) + broadcast_to_members(members, event_name, broadcast_data) + end + + private + + # Ensures that only the latest available data is sent to prevent UI issues + # caused by out-of-order events during high-traffic periods. This prevents + # the conversation job from processing outdated data. + def prepare_broadcast_data(event_name, data) + return data unless event_name == 'conversation.updated' + + account = Account.find(data[:account_id]) + conversation = account.conversations.find_by!(display_id: data[:id]) + conversation.push_event_data.merge(account_id: data[:account_id]) + end + + def broadcast_to_members(members, event_name, broadcast_data) members.each do |member| - ActionCable.server.broadcast(member, { event: event_name, data: data }) + ActionCable.server.broadcast( + member, + { + event: event_name, + data: broadcast_data + } + ) end end end From 4b12a8a51e7414353b24700f28224858d5c418a7 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 11 Feb 2025 00:33:45 -0800 Subject: [PATCH 005/578] chore: Add more conversation events for reload (#10877) Followup PR for https://github.com/chatwoot/chatwoot/pull/10876. This PR just adds all the events related to conversation update to be reloaded before sending it to the UI. --- app/jobs/action_cable_broadcast_job.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/jobs/action_cable_broadcast_job.rb b/app/jobs/action_cable_broadcast_job.rb index ce1230029..74dab618d 100644 --- a/app/jobs/action_cable_broadcast_job.rb +++ b/app/jobs/action_cable_broadcast_job.rb @@ -1,5 +1,14 @@ class ActionCableBroadcastJob < ApplicationJob queue_as :critical + include Events::Types + + CONVERSATION_UPDATE_EVENTS = [ + CONVERSATION_READ, + CONVERSATION_UPDATED, + TEAM_CHANGED, + ASSIGNEE_CHANGED, + CONVERSATION_STATUS_CHANGED + ].freeze def perform(members, event_name, data) return if members.blank? @@ -14,7 +23,7 @@ class ActionCableBroadcastJob < ApplicationJob # caused by out-of-order events during high-traffic periods. This prevents # the conversation job from processing outdated data. def prepare_broadcast_data(event_name, data) - return data unless event_name == 'conversation.updated' + return data unless CONVERSATION_UPDATE_EVENTS.include?(event_name) account = Account.find(data[:account_id]) conversation = account.conversations.find_by!(display_id: data[:id]) From a428dfc3f447665487cdc3e56d8554d6085177bf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 11 Feb 2025 17:45:31 +0530 Subject: [PATCH 006/578] feat: handle mine event for incoming messages (#10867) Handle `mine` condition, missed in https://github.com/chatwoot/chatwoot/pull/10529 --- .../helper/AudioAlerts/DashboardAudioNotificationHelper.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js index 21cb0fc9f..9e79bb3a2 100644 --- a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js +++ b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js @@ -152,7 +152,10 @@ export class DashboardAudioNotificationHelper { const shouldPlayAudio = []; - if (audioAlertType.includes(EVENT_TYPES.ASSIGNED)) { + if ( + audioAlertType.includes(EVENT_TYPES.ASSIGNED) || + audioAlertType.includes('mine') + ) { shouldPlayAudio.push(assignedToMe); } if (audioAlertType.includes(EVENT_TYPES.UNASSIGNED)) { From 84822a013a01808829071a28f4f027130e45f58b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 11 Feb 2025 17:45:59 +0530 Subject: [PATCH 007/578] fix: inconsistent reply box cc update (#10799) This PR target two issues ### CC & BCC not updated correctly When moving from one conversation to another, the store may not have the list of all the messages. A fetch is subsequently made to get the messages. However, this update does not trigger the `currentChat` watcher. This PR fixes it by adding a new watcher on `currentChat.messages`. We also update the `setCCAndToEmailsFromLastChat` method to reset the `cc`, `bcc` and `to` fields if the last email is not found. This ensures that the data is not carried forward from a previous email Fixes: https://github.com/chatwoot/chatwoot/issues/10477 ### To address are not added correctly to the `CC` If the `to` address of a previous email has multiple recipient, there was no case to add them to the CC. Fixes: https://github.com/chatwoot/chatwoot/issues/8925 --- Depends on: https://github.com/chatwoot/utils/pull/41 --- .../widgets/conversation/ReplyBox.vue | 57 +++++++------------ .../store/modules/conversations/getters.js | 18 ++---- package.json | 2 +- pnpm-lock.yaml | 28 ++++++--- 4 files changed, 48 insertions(+), 57 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index d3675dbac..7a876aaa6 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -30,7 +30,7 @@ import { import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; -import { trimContent, debounce } from '@chatwoot/utils'; +import { trimContent, debounce, getRecipients } from '@chatwoot/utils'; import wootConstants from 'dashboard/constants/globals'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; @@ -388,7 +388,6 @@ export default { watch: { currentChat(conversation) { const { can_reply: canReply } = conversation; - this.setCCAndToEmailsFromLastChat(); if (this.isOnPrivateNote) { @@ -403,6 +402,19 @@ export default { this.fetchAndSetReplyTo(); }, + // When moving from one conversation to another, the store may not have the + // list of all the messages. A fetch is subsequently made to get the messages. + // However, this update does not trigger the `currentChat` watcher. + // We can add a deep watcher to it, but then, that would be too broad of a net to cast + // And would impact performance too. So we watch the messages directly. + // The watcher here is `deep` too, because the messages array is mutated and + // not replaced. So, a shallow watcher would not catch the change. + 'currentChat.messages': { + handler() { + this.setCCAndToEmailsFromLastChat(); + }, + deep: true, + }, conversationIdByRoute(conversationId, oldConversationId) { if (conversationId !== oldConversationId) { this.setToDraft(oldConversationId, this.replyType); @@ -989,45 +1001,20 @@ export default { this.ccEmails = value.ccEmails; }, setCCAndToEmailsFromLastChat() { - if (!this.lastEmail) return; - - const { - content_attributes: { email: emailAttributes = {} }, - } = this.lastEmail; - - // Retrieve the email of the current conversation's sender const conversationContact = this.currentChat?.meta?.sender?.email || ''; - let cc = emailAttributes.cc ? [...emailAttributes.cc] : []; - let to = []; + const { email: inboxEmail, forward_to_email: forwardToEmail } = + this.inbox; - // there might be a situation where the current conversation will include a message from a third person, - // and the current conversation contact is in CC. - // This is an edge-case, reported here: CW-1511 [ONLY FOR INTERNAL REFERENCE] - // So we remove the current conversation contact's email from the CC list if present - if (cc.includes(conversationContact)) { - cc = cc.filter(email => email !== conversationContact); - } - - // If the last incoming message sender is different from the conversation contact, add them to the "to" - // and add the conversation contact to the CC - if (!emailAttributes.from.includes(conversationContact)) { - to.push(...emailAttributes.from); - cc.push(conversationContact); - } - - // Remove the conversation contact's email from the BCC list if present - let bcc = (emailAttributes.bcc || []).filter( - email => email !== conversationContact + const { cc, bcc, to } = getRecipients( + this.lastEmail, + conversationContact, + inboxEmail, + forwardToEmail ); - // Ensure only unique email addresses are in the CC list - bcc = [...new Set(bcc)]; - cc = [...new Set(cc)]; - to = [...new Set(to)]; - + this.toEmails = to.join(', '); this.ccEmails = cc.join(', '); this.bccEmails = bcc.join(', '); - this.toEmails = to.join(', '); }, fetchAndSetReplyTo() { const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO; diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index 085766bfd..3695de6a1 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -27,18 +27,12 @@ const getters = { const selectedChat = _getters.getSelectedChat; const { messages = [] } = selectedChat; const lastEmail = [...messages].reverse().find(message => { - const { - content_attributes: contentAttributes = {}, - message_type: messageType, - } = message; - const { email = {} } = contentAttributes; - const isIncomingOrOutgoing = - messageType === MESSAGE_TYPE.OUTGOING || - messageType === MESSAGE_TYPE.INCOMING; - if (email.from && isIncomingOrOutgoing) { - return true; - } - return false; + const { message_type: messageType } = message; + if (message.private) return false; + + return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes( + messageType + ); }); return lastEmail; diff --git a/package.json b/package.json index b3fbda8f0..aefa5439d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.1.1-next", - "@chatwoot/utils": "^0.0.35", + "@chatwoot/utils": "^0.0.38", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20142653e..6bbf22593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.1.1-next version: 1.1.1-next '@chatwoot/utils': - specifier: ^0.0.35 - version: 0.0.35 + specifier: ^0.0.38 + version: 0.0.38 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -376,6 +376,10 @@ packages: resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.26.7': + resolution: {integrity: sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.26.0': resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} @@ -393,8 +397,8 @@ packages: '@chatwoot/prosemirror-schema@1.1.1-next': resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==} - '@chatwoot/utils@0.0.35': - resolution: {integrity: sha512-uSRbd3pFp+IcEhsRtK1XGcFGFJc+X/YIwQnQrVDXsvsX3Mm7HEANj+Yz6J2clfHotajniwJwH2u5/y48+JrTyA==} + '@chatwoot/utils@0.0.38': + resolution: {integrity: sha512-6CTvuueBQLZJcm++pI2ZBY8Pp7OP3WzPCYyXoCagl8ZLpOfpjyVkLx9fc81falOoaVa/r+7EZ85Cv7vkT0ZyQw==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -2372,8 +2376,8 @@ packages: resolution: {integrity: sha512-m1WR0xGiC6j6jNFAyW4Nvh4WxAi4JF4w9jRJwSI8nBmNcyZXPcP9VUQG+6gHQXAmqaGEKDKhOqAtENDC941UkA==} engines: {node: '>=0.11'} - date-fns@2.29.3: - resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} + date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} date-format-parse@0.2.7: @@ -5092,6 +5096,10 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.26.7': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/types@7.26.0': dependencies: '@babel/helper-string-parser': 7.25.9 @@ -5125,9 +5133,9 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.35': + '@chatwoot/utils@0.0.38': dependencies: - date-fns: 2.29.3 + date-fns: 2.30.0 '@codemirror/commands@6.7.0': dependencies: @@ -7380,7 +7388,9 @@ snapshots: date-fns@2.21.1: {} - date-fns@2.29.3: {} + date-fns@2.30.0: + dependencies: + '@babel/runtime': 7.26.7 date-format-parse@0.2.7: {} From cf025e0fa4302e496f4f27db6dcc92af195f4c31 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 11 Feb 2025 18:58:36 +0530 Subject: [PATCH 008/578] chore: Remove the background SVG from the help center (#10857) # Pull Request Template ## Description This PR will remove the hexagon background image from public portal. Fixes https://linear.app/chatwoot/issue/CW-4013/remove-the-background-from-the-help-center ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Screenshots** **Before** image image **After** image image ## 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 --- app/helpers/portal_helper.rb | 3 +-- spec/helpers/portal_helper_spec.rb | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 342bb62aa..2b1ab7b21 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -5,8 +5,7 @@ module PortalHelper end def generate_portal_bg(portal_color, theme) - bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' - "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + generate_portal_bg_color(portal_color, theme) end def generate_gradient_to_bottom(theme) diff --git a/spec/helpers/portal_helper_spec.rb b/spec/helpers/portal_helper_spec.rb index 84ed4ba72..dfd37138a 100644 --- a/spec/helpers/portal_helper_spec.rb +++ b/spec/helpers/portal_helper_spec.rb @@ -33,26 +33,26 @@ describe PortalHelper do describe '#generate_portal_bg' do context 'when theme is dark' do it 'returns the correct background with dark grid image and color mix with black' do - expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)' + expected_bg = 'color-mix(in srgb, #ff0000 20%, black)' expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg) end end context 'when theme is not dark' do it 'returns the correct background with light grid image and color mix with white' do - expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)' + expected_bg = 'color-mix(in srgb, #ff0000 20%, white)' expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg) end end context 'when provided with various colors' do it 'adjusts the background appropriately for dark theme' do - expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)' + expected_bg = 'color-mix(in srgb, #00ff00 20%, black)' expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg) end it 'adjusts the background appropriately for light theme' do - expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)' + expected_bg = 'color-mix(in srgb, #0000ff 20%, white)' expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg) end end From a780de4b640da515939a8e0ead0bb889437a41ca Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 11 Feb 2025 19:15:46 +0530 Subject: [PATCH 009/578] refactor: show deprecation warnings in dev only (#10868) Fixes: https://github.com/chatwoot/chatwoot/issues/10734 --- app/javascript/dashboard/components/Modal.vue | 2 +- app/javascript/dashboard/components/buttons/Button.vue | 10 ++++++---- .../dashboard/components/widgets/forms/Input.vue | 10 ++++++---- app/javascript/v3/components/Button/SubmitButton.vue | 10 ++++++---- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index a1f7ba5ed..653e6cde0 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -57,7 +57,7 @@ useEventListener(document.body, 'mouseup', onMouseUp); useEventListener(document, 'keydown', onKeydown); onMounted(() => { - if (onClose && typeof onClose === 'function') { + if (import.meta.env.DEV && onClose && typeof onClose === 'function') { // eslint-disable-next-line no-console console.warn( "[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead." diff --git a/app/javascript/dashboard/components/buttons/Button.vue b/app/javascript/dashboard/components/buttons/Button.vue index 1ec445890..64b5ecb9a 100644 --- a/app/javascript/dashboard/components/buttons/Button.vue +++ b/app/javascript/dashboard/components/buttons/Button.vue @@ -28,10 +28,12 @@ export default { }, }, created() { - // eslint-disable-next-line - console.warn( - '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead' - ); + if (import.meta.env.DEV) { + // eslint-disable-next-line + console.warn( + '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead' + ); + } }, }; diff --git a/app/javascript/dashboard/components/widgets/forms/Input.vue b/app/javascript/dashboard/components/widgets/forms/Input.vue index b34d81259..bd6d49470 100644 --- a/app/javascript/dashboard/components/widgets/forms/Input.vue +++ b/app/javascript/dashboard/components/widgets/forms/Input.vue @@ -40,10 +40,12 @@ export default { }, emits: ['update:modelValue', 'input', 'blur'], mounted() { - // eslint-disable-next-line - console.warn( - '[DEPRECATED] has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead' - ); + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.warn( + '[DEPRECATED] has be deprecated and will be removed soon. Please use v3/components/Form/Input.vue instead' + ); + } }, methods: { onChange(e) { diff --git a/app/javascript/v3/components/Button/SubmitButton.vue b/app/javascript/v3/components/Button/SubmitButton.vue index 24c04acbb..88dad0799 100644 --- a/app/javascript/v3/components/Button/SubmitButton.vue +++ b/app/javascript/v3/components/Button/SubmitButton.vue @@ -40,10 +40,12 @@ export default { }, }, created() { - // eslint-disable-next-line - console.warn( - '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead' - ); + if (import.meta.env.DEV) { + // eslint-disable-next-line + console.warn( + '[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead' + ); + } }, }; From 55d41b112b0558c3afa79718e1f3b8864b18893c Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:39:54 +0530 Subject: [PATCH 010/578] feat: Show shared contact's name in Telegram channel (#10856) # Pull Request Template ## Description This PR adds the ability to see the shared contact name in Telegram channels. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Loom video** https://www.loom.com/share/cd318056ad4d44d4a1fc4b5d4ad38d60?sid=26d833ae-ded9-4cf0-9af7-81eecfa37f19 ## 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 - [ ] 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 - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra --- .../message/bubbles/BaseAttachment.vue | 4 ++ .../message/bubbles/Contact.vue | 15 +++-- app/models/attachment.rb | 4 +- .../telegram/incoming_message_service.rb | 6 +- .../20250207040150_add_meta_to_attachment.rb | 5 ++ db/schema.rb | 3 +- spec/models/attachment_spec.rb | 61 +++++++++++++++++++ 7 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 db/migrate/20250207040150_add_meta_to_attachment.rb diff --git a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue index d494de908..8beceee26 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue @@ -10,6 +10,7 @@ defineProps({ iconBgColor: { type: String, default: 'bg-n-alpha-3' }, senderTranslationKey: { type: String, required: true }, content: { type: String, required: true }, + title: { type: String, default: '' }, // Title can be any name, description, etc action: { type: Object, required: true, @@ -48,6 +49,9 @@ const senderName = computed(() => { }} +
+ {{ title }} +
{{ content }}
diff --git a/app/javascript/dashboard/components-next/message/bubbles/Contact.vue b/app/javascript/dashboard/components-next/message/bubbles/Contact.vue index 7230a7e05..dda249559 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Contact.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Contact.vue @@ -11,7 +11,7 @@ import { ExceptionWithMessage, } from 'shared/helpers/CustomErrors'; -const { content, attachments } = useMessageContext(); +const { attachments } = useMessageContext(); const $store = useStore(); const { t } = useI18n(); @@ -24,6 +24,12 @@ const phoneNumber = computed(() => { return attachment.value.fallbackTitle; }); +const contactName = computed(() => { + const { meta } = attachment.value ?? {}; + const { firstName, lastName } = meta ?? {}; + return `${firstName ?? ''} ${lastName ?? ''}`.trim(); +}); + const formattedPhoneNumber = computed(() => { return phoneNumber.value.replace(/\s|-|[A-Za-z]/g, ''); }); @@ -32,13 +38,9 @@ const rawPhoneNumber = computed(() => { return phoneNumber.value.replace(/\D/g, ''); }); -const name = computed(() => { - return content.value; -}); - function getContactObject() { const contactItem = { - name: name.value, + name: contactName.value, phone_number: `+${rawPhoneNumber.value}`, }; return contactItem; @@ -99,6 +101,7 @@ const action = computed(() => ({ icon="i-teenyicons-user-circle-solid" icon-bg-color="bg-[#D6409F]" sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.CONTACT" + :title="contactName" :content="phoneNumber" :action="formattedPhoneNumber ? action : null" /> diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 65e56a21f..0bfd9a978 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -9,6 +9,7 @@ # external_url :string # fallback_title :string # file_type :integer default("image") +# meta :jsonb # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -116,7 +117,8 @@ class Attachment < ApplicationRecord def contact_metadata { - fallback_title: fallback_title + fallback_title: fallback_title, + meta: meta || {} } end diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index d39005030..e337a03f6 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -143,7 +143,11 @@ class Telegram::IncomingMessageService @message.attachments.new( account_id: @message.account_id, file_type: :contact, - fallback_title: contact_card['phone_number'].to_s + fallback_title: contact_card['phone_number'].to_s, + meta: { + first_name: contact_card['first_name'], + last_name: contact_card['last_name'] + } ) end diff --git a/db/migrate/20250207040150_add_meta_to_attachment.rb b/db/migrate/20250207040150_add_meta_to_attachment.rb new file mode 100644 index 000000000..32188b4d7 --- /dev/null +++ b/db/migrate/20250207040150_add_meta_to_attachment.rb @@ -0,0 +1,5 @@ +class AddMetaToAttachment < ActiveRecord::Migration[7.0] + def change + add_column :attachments, :meta, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 17e5c46da..6bea44c8b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_01_16_061033) do +ActiveRecord::Schema[7.0].define(version: 2025_02_07_040150) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -174,6 +174,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_01_16_061033) do t.datetime "updated_at", precision: nil, null: false t.string "fallback_title" t.string "extension" + t.jsonb "meta", default: {} t.index ["account_id"], name: "index_attachments_on_account_id" t.index ["message_id"], name: "index_attachments_on_message_id" end diff --git a/spec/models/attachment_spec.rb b/spec/models/attachment_spec.rb index b1d19bd0e..241125538 100644 --- a/spec/models/attachment_spec.rb +++ b/spec/models/attachment_spec.rb @@ -67,4 +67,65 @@ RSpec.describe Attachment do expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url end end + + describe 'meta data handling' do + let(:message) { create(:message) } + + context 'when attachment is a contact type' do + let(:contact_attachment) do + message.attachments.create!( + account_id: message.account_id, + file_type: :contact, + fallback_title: '+1234567890', + meta: { + first_name: 'John', + last_name: 'Doe' + } + ) + end + + it 'stores and retrieves meta data correctly' do + expect(contact_attachment.meta['first_name']).to eq('John') + expect(contact_attachment.meta['last_name']).to eq('Doe') + end + + it 'includes meta data in push_event_data' do + event_data = contact_attachment.push_event_data + expect(event_data[:meta]).to eq({ + 'first_name' => 'John', + 'last_name' => 'Doe' + }) + end + + it 'returns empty hash for meta if not set' do + attachment = message.attachments.create!( + account_id: message.account_id, + file_type: :contact, + fallback_title: '+1234567890' + ) + expect(attachment.push_event_data[:meta]).to eq({}) + end + end + + context 'when meta is used with other file types' do + let(:image_attachment) do + attachment = message.attachments.new( + account_id: message.account_id, + file_type: :image, + meta: { description: 'Test image' } + ) + attachment.file.attach( + io: Rails.root.join('spec/assets/avatar.png').open, + filename: 'avatar.png', + content_type: 'image/png' + ) + attachment.save! + attachment + end + + it 'preserves meta data with file attachments' do + expect(image_attachment.meta['description']).to eq('Test image') + end + end + end end From c8387799323798a1a03f60cc53721872e1c02c81 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 12 Feb 2025 06:06:20 +0530 Subject: [PATCH 011/578] feat: Use feature flags across the routes (#10797) --- .../routes/dashboard/settings/agentBots/agentBot.routes.js | 4 ++++ .../routes/dashboard/settings/agents/agent.routes.js | 2 ++ .../dashboard/settings/attributes/attributes.routes.js | 2 ++ .../routes/dashboard/settings/auditlogs/audit.routes.js | 2 ++ .../dashboard/settings/automation/automation.routes.js | 2 ++ .../routes/dashboard/settings/canned/canned.routes.js | 2 ++ .../dashboard/settings/customRoles/customRole.routes.js | 2 ++ .../routes/dashboard/settings/inbox/inbox.routes.js | 7 +++++++ .../dashboard/settings/integrations/integrations.routes.js | 6 ++++++ .../routes/dashboard/settings/labels/labels.routes.js | 2 ++ .../routes/dashboard/settings/macros/macros.routes.js | 4 ++++ .../dashboard/routes/dashboard/settings/sla/sla.routes.js | 3 +++ .../routes/dashboard/settings/teams/teams.routes.js | 2 +- 13 files changed, 39 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js index 5d93ea01d..b3942d0fe 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import Bot from './Index.vue'; import CsmlEditBot from './csml/Edit.vue'; import CsmlNewBot from './csml/New.vue'; @@ -23,6 +24,7 @@ export default { name: 'agent_bots', component: Bot, meta: { + featureFlag: FEATURE_FLAGS.AGENT_BOTS, permissions: ['administrator'], }, }, @@ -31,6 +33,7 @@ export default { name: 'agent_bots_csml_new', component: CsmlNewBot, meta: { + featureFlag: FEATURE_FLAGS.AGENT_BOTS, permissions: ['administrator'], }, }, @@ -39,6 +42,7 @@ export default { name: 'agent_bots_csml_edit', component: CsmlEditBot, meta: { + featureFlag: FEATURE_FLAGS.AGENT_BOTS, permissions: ['administrator'], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js b/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js index 0e649fabe..e365d8e73 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/agents/agent.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import AgentHome from './Index.vue'; @@ -19,6 +20,7 @@ export default { name: 'agent_list', component: AgentHome, meta: { + featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT, permissions: ['administrator'], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/attributes.routes.js b/app/javascript/dashboard/routes/dashboard/settings/attributes/attributes.routes.js index f74727059..bd9183572 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/attributes/attributes.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/attributes.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import AttributesHome from './Index.vue'; @@ -19,6 +20,7 @@ export default { name: 'attributes_list', component: AttributesHome, meta: { + featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES, permissions: ['administrator'], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/auditlogs/audit.routes.js b/app/javascript/dashboard/routes/dashboard/settings/auditlogs/audit.routes.js index 4d9e5d9ce..545c94eeb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/auditlogs/audit.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/auditlogs/audit.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; @@ -19,6 +20,7 @@ export default { path: 'list', name: 'auditlogs_list', meta: { + featureFlag: FEATURE_FLAGS.AUDIT_LOGS, permissions: ['administrator'], }, component: AuditLogsHome, diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/automation.routes.js b/app/javascript/dashboard/routes/dashboard/settings/automation/automation.routes.js index 302e476bb..81cfc6cc4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/automation.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/automation.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import Automation from './Index.vue'; @@ -19,6 +20,7 @@ export default { name: 'automation_list', component: Automation, meta: { + featureFlag: FEATURE_FLAGS.AUTOMATIONS, permissions: ['administrator'], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js b/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js index 600d2664b..f9dbb6417 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/canned.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import { ROLES, @@ -22,6 +23,7 @@ export default { path: 'list', name: 'canned_list', meta: { + featureFlag: FEATURE_FLAGS.CANNED_RESPONSES, permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], }, component: CannedHome, diff --git a/app/javascript/dashboard/routes/dashboard/settings/customRoles/customRole.routes.js b/app/javascript/dashboard/routes/dashboard/settings/customRoles/customRole.routes.js index af2fc43ae..361231e23 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/customRoles/customRole.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/customRoles/customRole.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from 'dashboard/helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; @@ -17,6 +18,7 @@ export default { path: 'list', name: 'custom_roles_list', meta: { + featureFlag: FEATURE_FLAGS.CUSTOM_ROLES, permissions: ['administrator'], }, component: CustomRolesHome, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js index aa1115a06..e837c68de 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import ChannelFactory from './ChannelFactory.vue'; @@ -27,6 +28,7 @@ export default { name: 'settings_inbox_list', component: InboxHome, meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, }, @@ -55,6 +57,7 @@ export default { name: 'settings_inbox_new', component: ChannelList, meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, }, @@ -63,6 +66,7 @@ export default { name: 'settings_inbox_finish', component: FinishSetup, meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, }, @@ -71,6 +75,7 @@ export default { name: 'settings_inboxes_page_channel', component: ChannelFactory, meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, props: route => { @@ -81,6 +86,7 @@ export default { path: ':inbox_id/agents', name: 'settings_inboxes_add_agents', meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, component: AddAgents, @@ -92,6 +98,7 @@ export default { name: 'settings_inbox_show', component: Settings, meta: { + featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT, permissions: ['administrator'], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js index 3bb3e4708..a2ec06ae0 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import IntegrationHooks from './IntegrationHooks.vue'; @@ -19,6 +20,7 @@ export default { name: 'settings_applications', component: Index, meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, }, @@ -27,6 +29,7 @@ export default { component: DashboardApps, name: 'settings_integrations_dashboard_apps', meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, }, @@ -35,6 +38,7 @@ export default { component: Webhook, name: 'settings_integrations_webhook', meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, }, @@ -62,6 +66,7 @@ export default { name: 'settings_integrations_slack', component: Slack, meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, props: route => ({ code: route.query.code }), @@ -71,6 +76,7 @@ export default { name: 'settings_applications_integration', component: IntegrationHooks, meta: { + featureFlag: FEATURE_FLAGS.INTEGRATIONS, permissions: ['administrator'], }, props: route => ({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/labels.routes.js b/app/javascript/dashboard/routes/dashboard/settings/labels/labels.routes.js index ed5a504ab..2cd258902 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/labels.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/labels.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; @@ -23,6 +24,7 @@ export default { path: 'list', name: 'labels_list', meta: { + featureFlag: FEATURE_FLAGS.LABELS, permissions: ['administrator'], }, component: Index, diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/macros.routes.js b/app/javascript/dashboard/routes/dashboard/settings/macros/macros.routes.js index e1d4729e6..c45775fe9 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/macros/macros.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/macros/macros.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from 'dashboard/helper/URLHelper'; import { @@ -20,6 +21,7 @@ export default { name: 'macros_wrapper', component: Macros, meta: { + featureFlag: FEATURE_FLAGS.MACROS, permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], }, }, @@ -41,6 +43,7 @@ export default { name: 'macros_edit', component: MacroEditor, meta: { + featureFlag: FEATURE_FLAGS.MACROS, permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], }, }, @@ -49,6 +52,7 @@ export default { name: 'macros_new', component: MacroEditor, meta: { + featureFlag: FEATURE_FLAGS.MACROS, permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js index 6955775b9..50ad28472 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js @@ -1,3 +1,4 @@ +import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; @@ -14,6 +15,7 @@ export default { path: '', name: 'sla_wrapper', meta: { + featureFlag: FEATURE_FLAGS.SLA, permissions: ['administrator'], }, redirect: to => { @@ -24,6 +26,7 @@ export default { path: 'list', name: 'sla_list', meta: { + featureFlag: FEATURE_FLAGS.SLA, permissions: ['administrator'], }, component: Index, diff --git a/app/javascript/dashboard/routes/dashboard/settings/teams/teams.routes.js b/app/javascript/dashboard/routes/dashboard/settings/teams/teams.routes.js index d360573a5..39dc603cf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/teams/teams.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/teams/teams.routes.js @@ -1,5 +1,5 @@ import { frontendURL } from '../../../../helper/URLHelper'; -import { FEATURE_FLAGS } from 'dashboard/featureFlags'; +import { FEATURE_FLAGS } from '../../../../featureFlags'; import TeamsIndex from './Index.vue'; import CreateStepWrap from './Create/Index.vue'; From b3f616da76e68fcdf90f017be4ee1e6e45f7224b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 12 Feb 2025 11:52:01 +0530 Subject: [PATCH 012/578] feat: upgrade utils (#10884) --- package.json | 2 +- pnpm-lock.yaml | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index aefa5439d..5e026a488 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.1.1-next", - "@chatwoot/utils": "^0.0.38", + "@chatwoot/utils": "^0.0.39", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bbf22593..bb3403914 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: specifier: 1.1.1-next version: 1.1.1-next '@chatwoot/utils': - specifier: ^0.0.38 - version: 0.0.38 + specifier: ^0.0.39 + version: 0.0.39 '@formkit/core': specifier: ^1.6.7 version: 1.6.7 @@ -397,8 +397,8 @@ packages: '@chatwoot/prosemirror-schema@1.1.1-next': resolution: {integrity: sha512-/M2qZ+ZF7GlQNt1riwVP499fvp3hxSqd5iy8hxyF9pkj9qQ+OKYn5JK+v3qwwqQY3IxhmNOn1Lp6tm7vstrd9Q==} - '@chatwoot/utils@0.0.38': - resolution: {integrity: sha512-6CTvuueBQLZJcm++pI2ZBY8Pp7OP3WzPCYyXoCagl8ZLpOfpjyVkLx9fc81falOoaVa/r+7EZ85Cv7vkT0ZyQw==} + '@chatwoot/utils@0.0.39': + resolution: {integrity: sha512-m/mt8WhhgEBj0kyfxCQjwyVLVj4Eyb1QRvs1cckL82b6xjyW69zicsVVpTIGd2DLTVdGPu1NzLPKKPaTOnIZgQ==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5133,7 +5133,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.38': + '@chatwoot/utils@0.0.39': dependencies: date-fns: 2.30.0 @@ -6599,7 +6599,7 @@ snapshots: '@videojs/http-streaming@2.13.1(video.js@7.18.1)': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 '@videojs/vhs-utils': 3.0.4 aes-decrypter: 3.1.2 global: 4.4.0 @@ -6610,19 +6610,19 @@ snapshots: '@videojs/vhs-utils@3.0.4': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 global: 4.4.0 url-toolkit: 2.2.5 '@videojs/vhs-utils@3.0.5': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 global: 4.4.0 url-toolkit: 2.2.5 '@videojs/xhr@2.6.0': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 global: 4.4.0 is-function: 1.0.2 @@ -6905,7 +6905,7 @@ snapshots: aes-decrypter@3.1.2: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 pkcs7: 1.0.4 @@ -8678,7 +8678,7 @@ snapshots: m3u8-parser@4.7.0: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 '@videojs/vhs-utils': 3.0.5 global: 4.4.0 @@ -8813,7 +8813,7 @@ snapshots: mpd-parser@0.21.0: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 '@videojs/vhs-utils': 3.0.5 '@xmldom/xmldom': 0.7.13 global: 4.4.0 @@ -8832,7 +8832,7 @@ snapshots: mux.js@6.0.1: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 global: 4.4.0 mz@2.7.0: @@ -9066,7 +9066,7 @@ snapshots: pkcs7@1.0.4: dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.26.7 pkg-dir@7.0.0: dependencies: From cd80bd07ca9454ce3ff9e009e376c9ac13f50994 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:31:54 +0530 Subject: [PATCH 013/578] fix: TypeError - Cannot read properties of null (reading 'name') (#10887) --- .../components-next/message/bubbles/BaseAttachment.vue | 2 +- .../dashboard/components-next/message/bubbles/Dyte.vue | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue index 8beceee26..e10d68d64 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue @@ -24,7 +24,7 @@ const { sender } = useMessageContext(); const { t } = useI18n(); const senderName = computed(() => { - return sender?.value.name; + return sender?.value?.name || ''; }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue b/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue index d1fa326b4..61d3cf31d 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Dyte.vue @@ -9,7 +9,7 @@ import { useI18n } from 'vue-i18n'; import { useMessageContext } from '../provider.js'; import BaseAttachmentBubble from './BaseAttachment.vue'; -const { contentAttributes } = useMessageContext(); +const { content, sender, contentAttributes } = useMessageContext(); const { t } = useI18n(); @@ -53,6 +53,11 @@ const action = computed(() => ({ sender-translation-key="CONVERSATION.SHARED_ATTACHMENT.MEETING" :action="action" > +
+ + + {{ content }} +
-
- ) + EmbedRenderer.youtube(video_id) end def make_loom_embed(loom_match) video_id = loom_match[1] - %( -
- -
- ) + EmbedRenderer.loom(video_id) end def make_vimeo_embed(vimeo_match) video_id = vimeo_match[1] - %( -
- -
- ) + EmbedRenderer.vimeo(video_id) end def make_video_embed(link_url) - %( - - ) + EmbedRenderer.video(link_url) + end + + def make_wistia_embed(wistia_match) + video_id = wistia_match[2] + EmbedRenderer.wistia(video_id) end def make_arcade_embed(arcade_match) video_id = arcade_match[1] - %( -
- -
- ) + EmbedRenderer.arcade(video_id) end end diff --git a/lib/embed_renderer.rb b/lib/embed_renderer.rb new file mode 100644 index 000000000..0a747bbb3 --- /dev/null +++ b/lib/embed_renderer.rb @@ -0,0 +1,87 @@ +module EmbedRenderer + def self.youtube(video_id) + %( +
+ +
+ ) + end + + def self.loom(video_id) + %( +
+ +
+ ) + end + + def self.vimeo(video_id) + %( +
+ +
+ ) + end + + def self.video(link_url) + %( + + ) + 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) + %( +
+ + + + + +
+ ) + end + + def self.arcade(video_id) + %( +
+ +
+ ) + end +end diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb index 550e7b637..939965e91 100644 --- a/spec/lib/custom_markdown_renderer_spec.rb +++ b/spec/lib/custom_markdown_renderer_spec.rb @@ -143,6 +143,17 @@ describe CustomMarkdownRenderer do end end + context 'when link is a wistia URL' do + let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' } + + it 'renders a custom element with Wistia embed code' do + output = render_markdown_link(wistia_url) + expect(output).to include('') + expect(output).to include(' Date: Thu, 22 May 2025 21:20:28 +0530 Subject: [PATCH 328/578] 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. --- .../hook_execution_service.rb | 2 +- .../hook_execution_service_spec.rb | 45 ++++++++++++++----- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index bc161d885..a8a4c4318 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -27,7 +27,7 @@ class MessageTemplates::HookExecutionService 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 - return false if conversation.messages.outgoing.exists?(['created_at > ?', 5.minutes.ago]) + return false if conversation.messages.outgoing.where(private: false).exists?(['created_at > ?', 5.minutes.ago]) inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present? end diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index 9d9d82922..24e40ea8d 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -194,23 +194,44 @@ describe MessageTemplates::HookExecutionService do expect(out_of_office_service).to have_received(:perform) end - it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do - contact = create(:contact) - conversation = create(:conversation, contact: contact) + context 'with recent outgoing messages' do + it 'does not call ::MessageTemplates::Template::OutOfOffice when there are recent outgoing messages' do + contact = create(:contact) + conversation = create(:conversation, contact: contact) - conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') - conversation.inbox.working_hours.today.update!(closed_all_day: true) + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) - create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago) + create(:message, conversation: conversation, message_type: :outgoing, created_at: 2.minutes.ago) - out_of_office_service = double - allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) - allow(out_of_office_service).to receive(:perform).and_return(true) + out_of_office_service = double + allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) - create(:message, conversation: conversation) + create(:message, conversation: conversation) - expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new) - expect(out_of_office_service).not_to have_received(:perform) + expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new) + expect(out_of_office_service).not_to have_received(:perform) + end + + it 'ignores private note and calls ::MessageTemplates::Template::OutOfOffice' do + contact = create(:contact) + conversation = create(:conversation, contact: contact) + + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) + + create(:message, conversation: conversation, private: true, message_type: :outgoing, created_at: 2.minutes.ago) + + out_of_office_service = double + allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) + + create(:message, conversation: conversation) + + expect(MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: conversation) + expect(out_of_office_service).to have_received(:perform) + end end it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do From ea04a292ed3b9b61a6426362a3ab4b3e3e5166f4 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 22 May 2025 11:02:52 -0700 Subject: [PATCH 329/578] chore: Add an introduction guide to the developer docs (#11553) --- developer-docs/docs.json | 134 ++++++++++++++++++-------------- developer-docs/introduction.mdx | 49 ++++++++++++ 2 files changed, 124 insertions(+), 59 deletions(-) create mode 100644 developer-docs/introduction.mdx diff --git a/developer-docs/docs.json b/developer-docs/docs.json index 7507fbb28..a2ff9d1f6 100644 --- a/developer-docs/docs.json +++ b/developer-docs/docs.json @@ -1,4 +1,5 @@ { + "$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": { @@ -11,68 +12,83 @@ "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": { - "tabs": [ + "groups": [ { - "tab": "API Documentation", - "groups": [ - { - "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": "Application API", - "description": "APIs for managing application aspects of Chatwoot", - "includeTags": [ - "Account AgentBots", - "Agents", - "Canned Responses", - "Contacts", - "Contact Labels", - "Conversation Assignments", - "Conversation Labels", - "Conversations", - "Custom Attributes", - "Custom Filters", - "Inboxes", - "Integrations", - "Messages", - "Profile", - "Reports", - "Teams", - "Webhooks", - "Automation Rule", - "Help Center" - ], - "openapi": "https://raw.githubusercontent.com/chatwoot/chatwoot/develop/swagger/tag_groups/application_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" - } + "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" } ] }, @@ -81,4 +97,4 @@ "github": "https://github.com/chatwoot", "linkedin": "https://www.linkedin.com/company/chatwoot" } -} \ No newline at end of file +} diff --git a/developer-docs/introduction.mdx b/developer-docs/introduction.mdx new file mode 100644 index 000000000..28f8436ea --- /dev/null +++ b/developer-docs/introduction.mdx @@ -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. From e92f72b3183df21e69493cde7f250464f196121c Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 23 May 2025 09:53:40 +0530 Subject: [PATCH 330/578] chore: Prevent online presence during impersonation (#11538) --- app/javascript/dashboard/helper/actionCable.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index adb33eb6d..515806b31 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -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); From 8c0885e1d2a24ccad66f0ee1fd83476b9f440299 Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 22 May 2025 22:25:05 -0700 Subject: [PATCH 331/578] 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) --- app/listeners/action_cable_listener.rb | 2 + config/routes.rb | 4 +- ...024825_remove_uuid_from_copilot_threads.rb | 8 +++ ...26_remove_user_id_from_copilot_messages.rb | 5 ++ ...age_type_to_integer_in_copilot_messages.rb | 11 ++++ db/schema.rb | 10 ++- .../captain/copilot_messages_controller.rb | 13 +++- .../captain/copilot_threads_controller.rb | 29 ++++++++- .../{ => app}/listeners/captain_listener.rb | 3 + .../enterprise/action_cable_listener.rb | 11 ++++ enterprise/app/models/captain/assistant.rb | 1 + enterprise/app/models/copilot_message.rb | 33 ++++++++-- enterprise/app/models/copilot_thread.rb | 46 +++++++++---- .../copilot_messages/create.json.jbuilder | 1 + .../copilot_messages/index.json.jbuilder | 5 +- .../copilot_threads/create.json.jbuilder | 1 + .../copilot_threads/index.json.jbuilder | 9 +-- .../captain/_copilot_message.json.jbuilder | 6 ++ .../captain/_copilot_thread.json.jbuilder | 6 ++ lib/events/types.rb | 3 + .../copilot_messages_controller_spec.rb | 55 ++++++++++++++-- .../copilot_threads_controller_spec.rb | 63 +++++++++++++++++- .../listeners/action_cable_listener_spec.rb | 24 +++++++ .../listeners/captain_listener_spec.rb | 57 +++++++++++++++++ .../enterprise/models/copilot_message_spec.rb | 64 +++++++++++++++++++ spec/enterprise/models/copilot_thread_spec.rb | 62 ++++++++++++++++++ spec/factories/captain/copilot_message.rb | 3 +- spec/factories/captain/copilot_thread.rb | 2 +- spec/listeners/action_cable_listener_spec.rb | 20 ++++++ 29 files changed, 505 insertions(+), 52 deletions(-) create mode 100644 db/migrate/20250523024825_remove_uuid_from_copilot_threads.rb create mode 100644 db/migrate/20250523024826_remove_user_id_from_copilot_messages.rb create mode 100644 db/migrate/20250523031839_change_message_type_to_integer_in_copilot_messages.rb rename enterprise/{ => app}/listeners/captain_listener.rb (94%) create mode 100644 enterprise/app/listeners/enterprise/action_cable_listener.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/copilot_messages/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/copilot_threads/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_copilot_message.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_copilot_thread.json.jbuilder create mode 100644 spec/enterprise/listeners/action_cable_listener_spec.rb create mode 100644 spec/enterprise/listeners/captain_listener_spec.rb create mode 100644 spec/enterprise/models/copilot_message_spec.rb create mode 100644 spec/enterprise/models/copilot_thread_spec.rb diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index 37dc939e7..109f6d344 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -204,3 +204,5 @@ class ActionCableListener < BaseListener ::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload) end end + +ActionCableListener.prepend_mod_with('ActionCableListener') diff --git a/config/routes.rb b/config/routes.rb index 13866f01e..4b4db7b6d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20250523024825_remove_uuid_from_copilot_threads.rb b/db/migrate/20250523024825_remove_uuid_from_copilot_threads.rb new file mode 100644 index 000000000..c979f473b --- /dev/null +++ b/db/migrate/20250523024825_remove_uuid_from_copilot_threads.rb @@ -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 diff --git a/db/migrate/20250523024826_remove_user_id_from_copilot_messages.rb b/db/migrate/20250523024826_remove_user_id_from_copilot_messages.rb new file mode 100644 index 000000000..0889095d6 --- /dev/null +++ b/db/migrate/20250523024826_remove_user_id_from_copilot_messages.rb @@ -0,0 +1,5 @@ +class RemoveUserIdFromCopilotMessages < ActiveRecord::Migration[7.1] + def change + remove_reference :copilot_messages, :user, index: true + end +end diff --git a/db/migrate/20250523031839_change_message_type_to_integer_in_copilot_messages.rb b/db/migrate/20250523031839_change_message_type_to_integer_in_copilot_messages.rb new file mode 100644 index 000000000..c3bed0bb3 --- /dev/null +++ b/db/migrate/20250523031839_change_message_type_to_integer_in_copilot_messages.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 9c78acd33..d065a8ff4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb index 2a30fba48..d7d79e9af 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -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 diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb index e313f448c..482acd7e7 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -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 diff --git a/enterprise/listeners/captain_listener.rb b/enterprise/app/listeners/captain_listener.rb similarity index 94% rename from enterprise/listeners/captain_listener.rb rename to enterprise/app/listeners/captain_listener.rb index adf156562..879fb2b7e 100644 --- a/enterprise/listeners/captain_listener.rb +++ b/enterprise/app/listeners/captain_listener.rb @@ -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? diff --git a/enterprise/app/listeners/enterprise/action_cable_listener.rb b/enterprise/app/listeners/enterprise/action_cable_listener.rb new file mode 100644 index 000000000..fbeee170a --- /dev/null +++ b/enterprise/app/listeners/enterprise/action_cable_listener.rb @@ -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 diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index ad441ec28..40cf99df9 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -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 diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb index 16ae2c3c9..90ec2a090 100644 --- a/enterprise/app/models/copilot_message.rb +++ b/enterprise/app/models/copilot_message.rb @@ -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 diff --git a/enterprise/app/models/copilot_thread.rb b/enterprise/app/models/copilot_thread.rb index 865418ad7..5dc41c244 100644 --- a/enterprise/app/models/copilot_thread.rb +++ b/enterprise/app/models/copilot_thread.rb @@ -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 diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/create.json.jbuilder new file mode 100644 index 000000000..d47ecdec4 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: @copilot_message diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder index ce0d5b175..42a4d2dfe 100644 --- a/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_messages/index.json.jbuilder @@ -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 diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/create.json.jbuilder new file mode 100644 index 000000000..f777d89e2 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/copilot_thread', formats: [:json], resource: @copilot_thread diff --git a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder index c06182ffd..7e1edae02 100644 --- a/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder +++ b/enterprise/app/views/api/v1/accounts/captain/copilot_threads/index.json.jbuilder @@ -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 diff --git a/enterprise/app/views/api/v1/models/captain/_copilot_message.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_copilot_message.json.jbuilder new file mode 100644 index 000000000..0ef1d9dd0 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_copilot_message.json.jbuilder @@ -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 diff --git a/enterprise/app/views/api/v1/models/captain/_copilot_thread.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_copilot_thread.json.jbuilder new file mode 100644 index 000000000..d014a3dd3 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_copilot_thread.json.jbuilder @@ -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 diff --git a/lib/events/types.rb b/lib/events/types.rb index ff7eaf778..dce6b8b32 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -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 diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb index 0ccca90c5..d435013cf 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb index 8533a2d1d..748c3adf6 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -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 diff --git a/spec/enterprise/listeners/action_cable_listener_spec.rb b/spec/enterprise/listeners/action_cable_listener_spec.rb new file mode 100644 index 000000000..9cb307809 --- /dev/null +++ b/spec/enterprise/listeners/action_cable_listener_spec.rb @@ -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 diff --git a/spec/enterprise/listeners/captain_listener_spec.rb b/spec/enterprise/listeners/captain_listener_spec.rb new file mode 100644 index 000000000..1363ea033 --- /dev/null +++ b/spec/enterprise/listeners/captain_listener_spec.rb @@ -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 diff --git a/spec/enterprise/models/copilot_message_spec.rb b/spec/enterprise/models/copilot_message_spec.rb new file mode 100644 index 000000000..e5c904c45 --- /dev/null +++ b/spec/enterprise/models/copilot_message_spec.rb @@ -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 diff --git a/spec/enterprise/models/copilot_thread_spec.rb b/spec/enterprise/models/copilot_thread_spec.rb new file mode 100644 index 000000000..37421ae76 --- /dev/null +++ b/spec/enterprise/models/copilot_thread_spec.rb @@ -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 diff --git a/spec/factories/captain/copilot_message.rb b/spec/factories/captain/copilot_message.rb index 78f9f202e..5d24d0ebf 100644 --- a/spec/factories/captain/copilot_message.rb +++ b/spec/factories/captain/copilot_message.rb @@ -1,9 +1,8 @@ FactoryBot.define do factory :captain_copilot_message, class: 'CopilotMessage' do account - user copilot_thread { association :captain_copilot_thread } message { { content: 'This is a test message' } } - message_type { 'user' } + message_type { 0 } end end diff --git a/spec/factories/captain/copilot_thread.rb b/spec/factories/captain/copilot_thread.rb index fee78a7e7..2c7a4170c 100644 --- a/spec/factories/captain/copilot_thread.rb +++ b/spec/factories/captain/copilot_thread.rb @@ -3,6 +3,6 @@ FactoryBot.define do account user title { Faker::Lorem.sentence } - uuid { SecureRandom.uuid } + assistant { create(:captain_assistant, account: account) } end end diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index 55b74116c..ea62d8ca7 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -203,4 +203,24 @@ describe ActionCableListener do listener.conversation_updated(event) end end + + 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) } + + 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 + ) + + listener.copilot_message_created(event) + end + end end From 72c5671e09578330177401bfc91db80ed58e6eca Mon Sep 17 00:00:00 2001 From: Pranav Date: Thu, 22 May 2025 23:03:10 -0700 Subject: [PATCH 332/578] feat: Add support for the temperature field (#11554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-05-22 at 3 10 22 PM This is a stop-gap solution. --- .../assistant/EditAssistantForm.vue | 24 ++++++++++++++++++- .../i18n/locale/en/integrations.json | 5 +++- .../accounts/captain/assistants_controller.rb | 2 +- enterprise/app/helpers/captain/chat_helper.rb | 3 ++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue index 362950802..3134d7062 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue @@ -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( -
+
+
+ +
+ + {{ state.temperature }} +
+

+ {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }} +

+