From 16371498ba01ee4c4265bf85d6fc0a8d706a1bee Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 19 May 2025 18:00:38 -0700 Subject: [PATCH 1/7] feat: Add a tool to search Linear Issues in copilot (#11518) This PR adds a tool to search Linear issues. If the integration is enabled for the account, the tool will return results as expected. Also introduces support for an `active?` method, which allows third-party Copilot tools to be conditionally enabled based on the status of the integration on the account. --- .../services/captain/tool_registry_service.rb | 2 + .../services/captain/tools/base_service.rb | 4 + .../copilot/search_linear_issues_service.rb | 77 +++++++++++ .../captain/tool_registry_service_spec.rb | 59 ++++++--- .../search_linear_issues_service_spec.rb | 125 ++++++++++++++++++ 5 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb create mode 100644 spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb diff --git a/enterprise/app/services/captain/tool_registry_service.rb b/enterprise/app/services/captain/tool_registry_service.rb index 088d89483..f2c234060 100644 --- a/enterprise/app/services/captain/tool_registry_service.rb +++ b/enterprise/app/services/captain/tool_registry_service.rb @@ -9,6 +9,8 @@ class Captain::ToolRegistryService def register_tool(tool_class) tool = tool_class.new(@assistant) + return unless tool.active? + @tools[tool.name] = tool @registered_tools << tool.to_registry_format end diff --git a/enterprise/app/services/captain/tools/base_service.rb b/enterprise/app/services/captain/tools/base_service.rb index 10e44d2f4..7fadc806b 100644 --- a/enterprise/app/services/captain/tools/base_service.rb +++ b/enterprise/app/services/captain/tools/base_service.rb @@ -31,4 +31,8 @@ class Captain::Tools::BaseService } } end + + def active? + true + end end diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb new file mode 100644 index 000000000..599fc7de9 --- /dev/null +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -0,0 +1,77 @@ +class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService + def name + 'search_linear_issues' + end + + def description + 'Search Linear issues based on a search term' + end + + def parameters + { + type: 'object', + properties: { + term: { + type: 'string', + description: 'The search term to find Linear issues' + } + }, + required: %w[term] + } + end + + def execute(arguments) + return 'Linear integration is not enabled' unless active? + + term = arguments['term'] + + Rails.logger.info "#{self.class.name}: Service called with the search term #{term}" + + return 'Missing required parameters' if term.blank? + + linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account) + result = linear_service.search_issue(term) + + return result[:error] if result[:error] + + issues = result[:data] + return 'No issues found, I should try another similar search term' if issues.blank? + + total_count = issues.length + + <<~RESPONSE + Total number of issues: #{total_count} + #{issues.map { |issue| format_issue(issue) }.join("\n---\n")} + RESPONSE + end + + def active? + @assistant.account.hooks.find_by(app_id: 'linear').present? + end + + private + + def format_issue(issue) + <<~ISSUE + Title: #{issue['title']} + ID: #{issue['id']} + State: #{issue['state']['name']} + Priority: #{format_priority(issue['priority'])} + #{issue['assignee'] ? "Assignee: #{issue['assignee']['name']}" : 'Assignee: Unassigned'} + #{issue['description'].present? ? "\nDescription: #{issue['description']}" : ''} + ISSUE + end + + def format_priority(priority) + return 'No priority' if priority.nil? + + case priority + when 0 then 'No priority' + when 1 then 'Urgent' + when 2 then 'High' + when 3 then 'Medium' + when 4 then 'Low' + else 'Unknown' + end + end +end diff --git a/spec/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb index e355dea0a..beb4a6a63 100644 --- a/spec/enterprise/services/captain/tool_registry_service_spec.rb +++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb @@ -2,6 +2,13 @@ require 'rails_helper' # Test tool implementation class TestTool < Captain::Tools::BaseService + attr_accessor :tool_active + + def initialize(*args) + super + @tool_active = true + end + def name 'test_tool' end @@ -24,6 +31,10 @@ class TestTool < Captain::Tools::BaseService def execute(*args) args end + + def active? + @tool_active + end end RSpec.describe Captain::ToolRegistryService do @@ -40,27 +51,41 @@ RSpec.describe Captain::ToolRegistryService do describe '#register_tool' do let(:tool_class) { TestTool } - it 'registers a new tool' do - service.register_tool(tool_class) - - expect(service.tools['test_tool']).to be_a(TestTool) - expect(service.registered_tools).to include( - { - type: 'function', - function: { - name: 'test_tool', - description: 'A test tool for specs', - parameters: { - type: 'object', - properties: { - test_param: { - type: 'string' + context 'when tool is active' do + it 'registers a new tool' do + service.register_tool(tool_class) + expect(service.tools['test_tool']).to be_a(TestTool) + expect(service.registered_tools).to include( + { + type: 'function', + function: { + name: 'test_tool', + description: 'A test tool for specs', + parameters: { + type: 'object', + properties: { + test_param: { + type: 'string' + } } } } } - } - ) + ) + end + end + + context 'when tool is inactive' do + it 'does not register the tool' do + tool = tool_class.new(assistant) + tool.tool_active = false + allow(tool_class).to receive(:new).and_return(tool) + + service.register_tool(tool_class) + + expect(service.tools['test_tool']).to be_nil + expect(service.registered_tools).to be_empty + end end end diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb new file mode 100644 index 000000000..f4a5225b1 --- /dev/null +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -0,0 +1,125 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:service) { described_class.new(assistant) } + + describe '#name' do + it 'returns the correct service name' do + expect(service.name).to eq('search_linear_issues') + end + end + + describe '#description' do + it 'returns the service description' do + expect(service.description).to eq('Search Linear issues based on a search term') + end + end + + describe '#parameters' do + it 'returns the expected parameter schema' do + expect(service.parameters).to eq( + { + type: 'object', + properties: { + term: { + type: 'string', + description: 'The search term to find Linear issues' + } + }, + required: %w[term] + } + ) + end + end + + describe '#active?' do + context 'when Linear integration is enabled' do + before do + create(:integrations_hook, :linear, account: account) + end + + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when Linear integration is not enabled' do + it 'returns false' do + expect(service.active?).to be false + end + end + end + + describe '#execute' do + context 'when Linear integration is not enabled' do + it 'returns error message' do + expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled') + end + end + + context 'when Linear integration is enabled' do + let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) } + + before do + create(:integrations_hook, :linear, account: account) + allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service) + end + + context 'when term is blank' do + it 'returns error message' do + expect(service.execute({ 'term' => '' })).to eq('Missing required parameters') + end + end + + context 'when search returns error' do + before do + allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' }) + end + + it 'returns the error message' do + expect(service.execute({ 'term' => 'test' })).to eq('API Error') + end + end + + context 'when search returns no issues' do + before do + allow(linear_service).to receive(:search_issue).and_return({ data: [] }) + end + + it 'returns no issues found message' do + expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term') + end + end + + context 'when search returns issues' do + let(:issues) do + [{ + 'title' => 'Test Issue', + 'id' => 'TEST-123', + 'state' => { 'name' => 'In Progress' }, + 'priority' => 4, + 'assignee' => { 'name' => 'John Doe' }, + 'description' => 'Test description' + }] + end + + before do + allow(linear_service).to receive(:search_issue).and_return({ data: issues }) + end + + it 'returns formatted issues' do + result = service.execute({ 'term' => 'test' }) + expect(result).to include('Total number of issues: 1') + expect(result).to include('Title: Test Issue') + expect(result).to include('ID: TEST-123') + expect(result).to include('State: In Progress') + expect(result).to include('Priority: Low') + expect(result).to include('Assignee: John Doe') + expect(result).to include('Description: Test description') + end + end + end + end +end From 98a50d12e047a49c863d3287c0946b4cacac14f1 Mon Sep 17 00:00:00 2001 From: Sojan Date: Tue, 20 May 2025 00:11:50 -0700 Subject: [PATCH 2/7] Bump version to 4.2.0 --- config/app.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/app.yml b/config/app.yml index 9afc3f7bc..36bbdbcf1 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.1.0' + version: '4.2.0' development: <<: *shared diff --git a/package.json b/package.json index 88c775f28..90b7708f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.1.0", + "version": "4.2.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From ccb3672ff88c85308750acead257abdae8702aa1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 20 May 2025 16:04:56 +0530 Subject: [PATCH 3/7] fix: Status not updating when creating a Linear issue (#11523) --- .../api/v1/accounts/integrations/linear_controller.rb | 3 ++- lib/linear.rb | 3 ++- .../api/v1/accounts/integrations/linear_controller_spec.rb | 1 + spec/lib/integrations/linear/processor_service_spec.rb | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index 4e5348e88..c66f06909 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -94,7 +94,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def permitted_params - params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) + params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, :state_id, + label_ids: []) end def fetch_hook diff --git a/lib/linear.rb b/lib/linear.rb index 9a998c34a..8bf967fc3 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -57,7 +57,8 @@ class Linear assigneeId: params[:assignee_id], priority: params[:priority], labelIds: params[:label_ids], - projectId: params[:project_id] + projectId: params[:project_id], + stateId: params[:state_id] }.compact mutation = Linear::Mutations.issue_create(variables) response = post({ query: mutation }) diff --git a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb index b1341e65e..0f27e2bd2 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -100,6 +100,7 @@ RSpec.describe 'Linear Integration API', type: :request do description: 'This is a sample issue.', assignee_id: 'user1', priority: 'high', + state_id: 'state1', label_ids: ['label1'] } end diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb index 07cf27654..807e93c71 100644 --- a/spec/lib/integrations/linear/processor_service_spec.rb +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -76,6 +76,7 @@ describe Integrations::Linear::ProcessorService do description: 'Issue description', assignee_id: 'user1', priority: 2, + state_id: 'state1', label_ids: %w[bug] } end From 27ec791353dadc990a67b10701e19981421f6470 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 21 May 2025 04:09:18 +0530 Subject: [PATCH 4/7] fix: Display message content for CSAT messages in non-widget inboxes (#11528) We made so many improvements for CSAT via https://github.com/chatwoot/chatwoot/pull/11485. However, we missed showing message content in the dashboard for CSAT URLs created in non-widget inboxes. This PR fixes the issue by ensuring that CSAT-configured messages are passed along with CSAT responses, otherwise defaulting to the translation. --- app/models/message.rb | 8 +++++++- spec/models/message_spec.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/models/message.rb b/app/models/message.rb index f5d7712d2..a952e0265 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -185,7 +185,13 @@ class Message < ApplicationRecord # move this to a presenter return self[:content] if !input_csat? || inbox.web_widget? - I18n.t('conversations.survey.response', link: "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}") + survey_link = "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation.uuid}" + + if inbox.csat_config&.dig('message').present? + "#{inbox.csat_config['message']} #{survey_link}" + else + I18n.t('conversations.survey.response', link: survey_link) + end end def email_notifiable_message? diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 4c66e37ee..b4be3ac5a 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -475,4 +475,35 @@ RSpec.describe Message do end end end + + describe '#content' do + let(:conversation) { create(:conversation) } + let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') } + + it 'returns original content for web widget inbox' do + allow(message.inbox).to receive(:web_widget?).and_return(true) + expect(message.content).to eq('Original content') + end + + context 'when inbox is not a web widget' do + before do + allow(message.inbox).to receive(:web_widget?).and_return(false) + allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com') + end + + it 'returns custom message with survey link when csat message is configured' do + allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' }) + expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(message.content).to eq(expected_content) + end + + it 'returns default message with survey link when no custom csat message' do + allow(message.inbox).to receive(:csat_config).and_return(nil) + allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}") + .and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}") + expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}" + expect(message.content).to eq(expected_content) + end + end + end end From 2ee63656e24633a8c74f6f0e1e724d8f7fed9a28 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 21 May 2025 06:04:30 +0530 Subject: [PATCH 5/7] feat: Prevent saving preferences and status when impersonating (#11164) This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings. Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements --- .../sidebar/SidebarProfileMenuStatus.vue | 7 + .../components/layout/AvailabilityStatus.vue | 12 ++ .../composables/spec/useImpersonation.spec.js | 37 +++++ .../dashboard/composables/useImpersonation.js | 10 ++ .../dashboard/constants/sessionStorage.js | 3 + .../dashboard/i18n/locale/en/settings.json | 3 +- .../dashboard/store/modules/auth.js | 13 +- app/javascript/dashboard/store/utils/api.js | 7 + .../shared/helpers/sessionStorage.js | 26 ++++ .../helpers/specs/sessionStorage.spec.js | 137 ++++++++++++++++++ app/javascript/v3/views/login/Index.vue | 14 +- app/models/concerns/sso_authenticatable.rb | 4 + app/views/super_admin/users/_impersonate.erb | 2 +- 13 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/useImpersonation.spec.js create mode 100644 app/javascript/dashboard/composables/useImpersonation.js create mode 100644 app/javascript/dashboard/constants/sessionStorage.js create mode 100644 app/javascript/shared/helpers/sessionStorage.js create mode 100644 app/javascript/shared/helpers/specs/sessionStorage.spec.js diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue index 0fdf96162..fef196162 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarProfileMenuStatus.vue @@ -4,6 +4,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store'; import wootConstants from 'dashboard/constants/globals'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; +import { useImpersonation } from 'dashboard/composables/useImpersonation'; import { DropdownContainer, @@ -20,6 +21,8 @@ const currentUserAvailability = useMapGetter('getCurrentUserAvailability'); const currentAccountId = useMapGetter('getCurrentAccountId'); const currentUserAutoOffline = useMapGetter('getCurrentUserAutoOffline'); +const { isImpersonating } = useImpersonation(); + const { AVAILABILITY_STATUS_KEYS } = wootConstants; const statusList = computed(() => { return [ @@ -46,6 +49,10 @@ const activeStatus = computed(() => { }); function changeAvailabilityStatus(availability) { + if (isImpersonating.value) { + useAlert(t('PROFILE_SETTINGS.FORM.AVAILABILITY.IMPERSONATING_ERROR')); + return; + } try { store.dispatch('updateAvailability', { availability, diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 04190c7a4..a4995aee8 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -1,6 +1,7 @@