From 2573f37d903c6f387e29d294e36d946408f94861 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 1 Jul 2025 12:04:35 +0530 Subject: [PATCH 1/3] chore: Replace `content` with `outgoing_content` in webhook data (#11829) The API channels don't receive CSAT survey URLs in webhook payloads since `webhook_data` uses content instead of outgoing_content. --- app/models/message.rb | 2 +- spec/models/message_spec.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/models/message.rb b/app/models/message.rb index d4036416e..cc04a088e 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -168,7 +168,7 @@ class Message < ApplicationRecord additional_attributes: additional_attributes, content_attributes: content_attributes, content_type: content_type, - content: content, + content: outgoing_content, conversation: conversation.webhook_data, created_at: created_at, id: id, diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 3bf64a231..6de36a55e 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -267,6 +267,23 @@ RSpec.describe Message do message = create(:message) expect(message.webhook_data.key?(:attachments)).to be false end + + it 'uses outgoing_content for webhook content' do + message = create(:message, content: 'Test content') + expect(message).to receive(:outgoing_content).and_return('Outgoing test content') + + webhook_data = message.webhook_data + expect(webhook_data[:content]).to eq('Outgoing test content') + end + + it 'includes CSAT survey link in webhook content for input_csat messages' do + inbox = create(:inbox, channel: create(:channel_api)) + conversation = create(:conversation, inbox: inbox) + message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience') + + expect(message.outgoing_content).to include('survey/responses/') + expect(message.webhook_data[:content]).to include('survey/responses/') + end end context 'when message is created' do From 14ba73fc63f0d1f4cd68d246e32014776069b124 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 1 Jul 2025 13:31:02 +0530 Subject: [PATCH 2/3] fix: Revoke Linear OAuth token when integration is deleted (#11838) When users delete the Linear integration from their Chatwoot dashboard, the access token remains valid in Linear's system. This causes the integration to still appear as connected in Linear's UI, even though it's been removed from Chatwoot. Users need to manually disconnect from Linear's side to fully remove the integration. https://www.loom.com/share/5c102cbdf02e49bcb7a6fa6d409b531a?sid=0c664250-c867-4fc8-b44d-e1c1165337a7 --- .../v1/accounts/integrations/linear_controller.rb | 12 ++++++++++++ lib/linear.rb | 9 +++++++++ .../accounts/integrations/linear_controller_spec.rb | 6 ++++++ 3 files changed, 27 insertions(+) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index bfdfff058..c121ec9a4 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas before_action :fetch_hook, only: [:destroy] def destroy + revoke_linear_token @hook.destroy! head :ok end @@ -120,4 +121,15 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def fetch_hook @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear') end + + def revoke_linear_token + return unless @hook&.access_token + + begin + linear_client = Linear.new(@hook.access_token) + linear_client.revoke_token + rescue StandardError => e + Rails.logger.error "Failed to revoke Linear token: #{e.message}" + end + end end diff --git a/lib/linear.rb b/lib/linear.rb index 8bf967fc3..d96be1b5d 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -1,5 +1,6 @@ class Linear BASE_URL = 'https://api.linear.app/graphql'.freeze + REVOKE_URL = 'https://api.linear.app/oauth/revoke'.freeze PRIORITY_LEVELS = (0..4).to_a def initialize(access_token) @@ -86,6 +87,14 @@ class Linear process_response(response) end + def revoke_token + response = HTTParty.post( + REVOKE_URL, + headers: { 'Authorization' => "Bearer #{@access_token}", 'Content-Type' => 'application/json' } + ) + response.success? + end + private def validate_team_and_title(params) 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 851c5dbaf..995b7c879 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -14,6 +14,12 @@ RSpec.describe 'Linear Integration API', type: :request do describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do it 'deletes the linear integration' do + # Stub the HTTP call to Linear's revoke endpoint + allow(HTTParty).to receive(:post).with( + 'https://api.linear.app/oauth/revoke', + anything + ).and_return(instance_double(HTTParty::Response, success?: true)) + delete "/api/v1/accounts/#{account.id}/integrations/linear", headers: agent.create_new_auth_token, as: :json From 01acbe3cda18056c8a6087468e16eb275b7187be Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 1 Jul 2025 16:49:26 +0530 Subject: [PATCH 3/3] feat: Add user attribution to Linear integration with actor authorization (#11774) - Add `actor=app` parameter to Linear OAuth authorization URL for consistent app-level authorization https://linear.app/developers/oauth-actor-authorization - Implement user attribution for Linear issue creation and linking using `createAsUser` and `displayIconUrl` parameters - Enhance Linear integration to properly attribute actions to specific Chatwoot agents **Note** - The displayIconUrl parameter is being sent correctly to Linear's GraphQL API (verified through testing), but there is an issues with icon is not attaching properly. - We might need to disconnect the integration connect again. --- .../integrations/linear_controller.rb | 4 +- app/models/integrations/app.rb | 3 +- lib/integrations/linear/processor_service.rb | 8 +-- lib/linear.rb | 57 +++++++++++---- lib/linear/mutations.rb | 16 ++++- .../integrations/linear_controller_spec.rb | 10 +-- .../linear/processor_service_spec.rb | 36 ++++++++-- spec/lib/linear_spec.rb | 69 +++++++++++++++++++ 8 files changed, 168 insertions(+), 35 deletions(-) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c121ec9a4..eb6525bb1 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas end def create_issue - issue = linear_processor_service.create_issue(permitted_params) + issue = linear_processor_service.create_issue(permitted_params, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else @@ -45,7 +45,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas def link_issue issue_id = permitted_params[:issue_id] title = permitted_params[:title] - issue = linear_processor_service.link_issue(conversation_link, issue_id, title) + issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 3b5cd821a..6a1378f1e 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -73,7 +73,8 @@ class Integrations::App "redirect_uri=#{self.class.linear_integration_url}", "state=#{encode_state}", 'scope=read,write', - 'prompt=consent' + 'prompt=consent', + 'actor=app' ].join('&') end diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb index a53d17f4c..a3447b79e 100644 --- a/lib/integrations/linear/processor_service.rb +++ b/lib/integrations/linear/processor_service.rb @@ -22,8 +22,8 @@ class Integrations::Linear::ProcessorService } end - def create_issue(params) - response = linear_client.create_issue(params) + def create_issue(params, user = nil) + response = linear_client.create_issue(params, user) return response if response[:error] { @@ -33,8 +33,8 @@ class Integrations::Linear::ProcessorService } end - def link_issue(link, issue_id, title) - response = linear_client.link_issue(link, issue_id, title) + def link_issue(link, issue_id, title, user = nil) + response = linear_client.link_issue(link, issue_id, title, user) return response if response[:error] { diff --git a/lib/linear.rb b/lib/linear.rb index d96be1b5d..2ffcf8cca 100644 --- a/lib/linear.rb +++ b/lib/linear.rb @@ -46,33 +46,24 @@ class Linear process_response(response) end - def create_issue(params) + def create_issue(params, user = nil) validate_team_and_title(params) validate_priority(params[:priority]) validate_label_ids(params[:label_ids]) - variables = { - title: params[:title], - teamId: params[:team_id], - description: params[:description], - assigneeId: params[:assignee_id], - priority: params[:priority], - labelIds: params[:label_ids], - projectId: params[:project_id], - stateId: params[:state_id] - }.compact + variables = build_issue_variables(params, user) mutation = Linear::Mutations.issue_create(variables) response = post({ query: mutation }) process_response(response) end - def link_issue(link, issue_id, title) + def link_issue(link, issue_id, title, user = nil) raise ArgumentError, 'Missing link' if link.blank? raise ArgumentError, 'Missing issue id' if issue_id.blank? - payload = { - query: Linear::Mutations.issue_link(issue_id, link, title) - } + link_params = build_link_params(issue_id, link, title, user) + payload = { query: Linear::Mutations.issue_link(link_params) } + response = post(payload) process_response(response) end @@ -97,6 +88,42 @@ class Linear private + def build_issue_variables(params, user) + variables = { + title: params[:title], + teamId: params[:team_id], + description: params[:description], + assigneeId: params[:assignee_id], + priority: params[:priority], + labelIds: params[:label_ids], + projectId: params[:project_id], + stateId: params[:state_id] + }.compact + + # Add user attribution if available + if user&.name.present? + variables[:createAsUser] = user.name + variables[:displayIconUrl] = user.avatar_url if user.avatar_url.present? + end + + variables + end + + def build_link_params(issue_id, link, title, user) + params = { + issue_id: issue_id, + link: link, + title: title + } + + if user.present? + params[:user_name] = user.name if user.name.present? + params[:user_avatar_url] = user.avatar_url if user.avatar_url.present? + end + + params + end + def validate_team_and_title(params) raise ArgumentError, 'Missing team id' if params[:team_id].blank? raise ArgumentError, 'Missing title' if params[:title].blank? diff --git a/lib/linear/mutations.rb b/lib/linear/mutations.rb index 04774c705..5f34e602b 100644 --- a/lib/linear/mutations.rb +++ b/lib/linear/mutations.rb @@ -32,10 +32,22 @@ module Linear::Mutations GRAPHQL end - def self.issue_link(issue_id, link, title) + def self.issue_link(params) + issue_id = params[:issue_id] + link = params[:link] + title = params[:title] + user_name = params[:user_name] + user_avatar_url = params[:user_avatar_url] + + user_params = [] + user_params << "createAsUser: #{graphql_value(user_name)}" if user_name.present? + user_params << "displayIconUrl: #{graphql_value(user_avatar_url)}" if user_avatar_url.present? + + user_params_str = user_params.any? ? ", #{user_params.join(', ')}" : '' + <<~GRAPHQL mutation { - attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}") { + attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) { success attachment { id 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 995b7c879..5f512b2bd 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -119,7 +119,7 @@ RSpec.describe 'Linear Integration API', type: :request do let(:created_issue) { { data: { identifier: 'ENG-123', title: 'Sample Issue' } } } it 'returns the created issue' do - allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue) + allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue) post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", params: issue_params, @@ -131,7 +131,7 @@ RSpec.describe 'Linear Integration API', type: :request do end it 'creates activity message when conversation is provided' do - allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(created_issue) + allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(created_issue) expect do post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", @@ -150,7 +150,7 @@ RSpec.describe 'Linear Integration API', type: :request do context 'when issue creation fails' do it 'returns error message and does not create activity message' do - allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys).and_return(error: 'error message') + allow(processor_service).to receive(:create_issue).with(issue_params.stringify_keys, agent).and_return(error: 'error message') expect do post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", @@ -177,7 +177,7 @@ RSpec.describe 'Linear Integration API', type: :request do let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } } it 'returns the linked issue and creates activity message' do - allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(linked_issue) + allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(linked_issue) expect do post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", @@ -199,7 +199,7 @@ RSpec.describe 'Linear Integration API', type: :request do context 'when issue linking fails' do it 'returns error message and does not create activity message' do - allow(processor_service).to receive(:link_issue).with(link, issue_id, title).and_return(error: 'error message') + allow(processor_service).to receive(:link_issue).with(link, issue_id, title, agent).and_return(error: 'error message') expect do post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb index 8830e658e..9af9e08d3 100644 --- a/spec/lib/integrations/linear/processor_service_spec.rb +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -80,6 +80,7 @@ describe Integrations::Linear::ProcessorService do label_ids: %w[bug] } end + let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } let(:issue_response) do { 'issueCreate' => { @@ -94,7 +95,7 @@ describe Integrations::Linear::ProcessorService do context 'when Linear client returns valid data' do it 'returns parsed issue data with identifier' do - allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response) + allow(linear_client).to receive(:create_issue).with(params, nil).and_return(issue_response) result = service.create_issue(params) expect(result).to eq({ data: { @@ -104,13 +105,27 @@ describe Integrations::Linear::ProcessorService do } }) end + + context 'when user is provided' do + it 'passes user to Linear client' do + allow(linear_client).to receive(:create_issue).with(params, user).and_return(issue_response) + result = service.create_issue(params, user) + expect(result).to eq({ + data: { + id: 'issue1', + title: 'Issue title', + identifier: 'ENG-123' + } + }) + end + end end context 'when Linear client returns an error' do let(:error_response) { { error: 'Some error message' } } it 'returns the error' do - allow(linear_client).to receive(:create_issue).with(params).and_return(error_response) + allow(linear_client).to receive(:create_issue).with(params, nil).and_return(error_response) result = service.create_issue(params) expect(result).to eq(error_response) end @@ -121,22 +136,31 @@ describe Integrations::Linear::ProcessorService do let(:link) { 'https://example.com' } let(:issue_id) { 'issue1' } let(:title) { 'Title' } + let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } } let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } } context 'when Linear client returns valid data' do it 'returns parsed link data' do - allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(link_issue_response) + allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(link_issue_response) result = service.link_issue(link, issue_id, title) expect(result).to eq(link_response) end + + context 'when user is provided' do + it 'passes user to Linear client' do + allow(linear_client).to receive(:link_issue).with(link, issue_id, title, user).and_return(link_issue_response) + result = service.link_issue(link, issue_id, title, user) + expect(result).to eq(link_response) + end + end end context 'when Linear client returns an error' do let(:error_response) { { error: 'Some error message' } } it 'returns the error' do - allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(error_response) + allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(error_response) result = service.link_issue(link, issue_id, title) expect(result).to eq(error_response) end @@ -237,7 +261,7 @@ describe Integrations::Linear::ProcessorService do } } - allow(linear_client).to receive(:create_issue).with(params).and_return(response) + allow(linear_client).to receive(:create_issue).with(params, nil).and_return(response) result = service.create_issue(params) expect(result[:data]).to have_key(:identifier) @@ -256,7 +280,7 @@ describe Integrations::Linear::ProcessorService do } } - allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response) + allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(response) result = service.link_issue(link, issue_id, title) expect(result[:data][:id]).to eq(issue_id) diff --git a/spec/lib/linear_spec.rb b/spec/lib/linear_spec.rb index 2ebaec1a8..e15f64382 100644 --- a/spec/lib/linear_spec.rb +++ b/spec/lib/linear_spec.rb @@ -91,6 +91,7 @@ describe Linear do label_ids: ['bug'] } end + let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } context 'when the API response is success' do before do @@ -103,6 +104,34 @@ describe Linear do expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } }) end + context 'when user is provided' do + it 'includes user attribution in the request' do + allow(linear_client).to receive(:post) do |payload| + expect(payload[:query]).to include('createAsUser: "John Doe"') + expect(payload[:query]).to include('displayIconUrl: "https://example.com/avatar.jpg"') + instance_double(HTTParty::Response, success?: true, + parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } }) + end + + linear_client.create_issue(params, user) + end + end + + context 'when user has no avatar' do + let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') } + + it 'includes only user name in the request' do + allow(linear_client).to receive(:post) do |payload| + expect(payload[:query]).to include('createAsUser: "Jane Doe"') + expect(payload[:query]).not_to include('displayIconUrl') + instance_double(HTTParty::Response, success?: true, + parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } }) + end + + linear_client.create_issue(params, user_no_avatar) + end + end + context 'when the priority is invalid' do let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } } @@ -182,6 +211,7 @@ describe Linear do let(:link) { 'https://example.com' } let(:issue_id) { 'issue1' } let(:title) { 'Title' } + let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') } context 'when the API response is success' do before do @@ -194,6 +224,45 @@ describe Linear do expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } }) end + context 'when user is provided' do + it 'includes user attribution in the request' do + expected_params = { + issue_id: issue_id, + link: link, + title: title, + user_name: 'John Doe', + user_avatar_url: 'https://example.com/avatar.jpg' + } + + expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original + allow(linear_client).to receive(:post).and_return( + instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } }) + ) + + linear_client.link_issue(link, issue_id, title, user) + end + end + + context 'when user has no avatar' do + let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') } + + it 'includes only user name in the request' do + expected_params = { + issue_id: issue_id, + link: link, + title: title, + user_name: 'Jane Doe' + } + + expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original + allow(linear_client).to receive(:post).and_return( + instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } }) + ) + + linear_client.link_issue(link, issue_id, title, user_no_avatar) + end + end + context 'when the link is missing' do let(:link) { '' }