diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb index abc68b26b..caa5a04d8 100644 --- a/app/builders/v2/reports/label_summary_builder.rb +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -31,7 +31,7 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder resolved_counts: fetch_resolved_counts(conversation_filter), resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), - reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours) + reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) } end @@ -63,7 +63,9 @@ class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder end def fetch_resolved_counts(conversation_filter) - fetch_counts(conversation_filter.merge(status: :resolved)) + # since the base query is ActsAsTaggableOn, + # the status :resolved won't automatically be converted to integer status + fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) end def fetch_counts(conversation_filter) diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c66f06909..bfdfff058 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] def destroy @@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_created, + issue_data: { id: issue[:data][:identifier] }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_linked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end def unlink_issue link_id = permitted_params[:link_id] + issue_id = permitted_params[:issue_id] issue = linear_processor_service.unlink_issue(link_id) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_unlinked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js index 2ac0940aa..bb327b7e8 100644 --- a/app/javascript/dashboard/api/integrations/linear.js +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -33,9 +33,11 @@ class LinearAPI extends ApiClient { ); } - unlinkIssue(linkId) { + unlinkIssue(linkId, issueIdentifier, conversationId) { return axios.post(`${this.url}/unlink_issue`, { link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js index e4bf679a6..3f33e3ed9 100644 --- a/app/javascript/dashboard/api/specs/integrations/linear.spec.js +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -91,6 +91,19 @@ describe('#linearAPI', () => { issueData ); }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); }); describe('link_issue', () => { @@ -120,6 +133,18 @@ describe('#linearAPI', () => { } ); }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); }); describe('getLinkedIssue', () => { @@ -164,12 +189,26 @@ describe('#linearAPI', () => { window.axios = originalAxios; }); - it('creates a valid request', () => { - LinearAPIClient.unlinkIssue(1); + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); expect(axiosMock.post).toHaveBeenCalledWith( '/api/v1/integrations/linear/unlink_issue', { - link_id: 1, + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, } ); }); diff --git a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue index c0766f382..e21c550c0 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue @@ -19,6 +19,7 @@ const isConversationRoute = computed(() => { 'conversation_through_mentions', 'conversation_through_unattended', 'conversation_through_participating', + 'inbox_view_conversation', ]; return CONVERSATION_ROUTES.includes(route.name); }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/EmailMeta.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/EmailMeta.vue index f4b863e64..905819ce6 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/EmailMeta.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/EmailMeta.vue @@ -14,7 +14,8 @@ const fromEmail = computed(() => { }); const toEmail = computed(() => { - return contentAttributes.value?.email?.to ?? []; + const { toEmails, email } = contentAttributes.value; + return email?.to ?? toEmails ?? []; }); const ccEmail = computed(() => { diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index b14a62f08..928eea7df 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -109,7 +109,6 @@ const advancedFilterTypes = ref( attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`), })) ); -const isInitialLoad = ref(false); const currentUser = useMapGetter('getCurrentUser'); const chatLists = useMapGetter('getFilteredConversations'); @@ -377,7 +376,6 @@ function setFiltersFromUISettings() { function emitConversationLoaded() { emit('conversationLoad'); - isInitialLoad.value = false; // [VITE] removing this since the library has changed // nextTick(() => { // // Addressing a known issue in the virtual list library where dynamically added items @@ -422,7 +420,6 @@ function onApplyFilter(payload) { foldersQuery.value = filterQueryGenerator(payload); store.dispatch('conversationPage/reset'); store.dispatch('emptyAllConversations'); - isInitialLoad.value = true; fetchFilteredConversations(payload); } @@ -577,7 +574,6 @@ function resetAndFetchData() { store.dispatch('conversationPage/reset'); store.dispatch('emptyAllConversations'); store.dispatch('clearConversationFilters'); - isInitialLoad.value = true; if (hasActiveFolders.value) { const payload = activeFolder.value.query; fetchSavedFilteredConversations(payload); @@ -858,7 +854,7 @@ watch(conversationFilters, (newVal, oldVal) => { :active-status="activeStatus" :is-on-expanded-layout="isOnExpandedLayout" :conversation-stats="conversationStats" - :is-list-loading="isInitialLoad" + :is-list-loading="chatListLoading && !conversationList.length" @add-folders="onClickOpenAddFoldersModal" @delete-folders="onClickOpenDeleteFoldersModal" @filters-modal="onToggleAdvanceFiltersModal" diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue b/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue index 5a276cc0f..9095b1bb8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/CreateIssue.vue @@ -183,13 +183,18 @@ const createIssue = async () => { state_id: formState.stateId || undefined, priority: formState.priority || undefined, label_ids: formState.labelId ? [formState.labelId] : undefined, + conversation_id: props.conversationId, }; try { isCreating.value = true; const response = await LinearAPI.createIssue(payload); - const { id: issueId } = response.data; - await LinearAPI.link_issue(props.conversationId, issueId, props.title); + const { identifier: issueIdentifier } = response.data; + await LinearAPI.link_issue( + props.conversationId, + issueIdentifier, + props.title + ); useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS')); useTrack(LINEAR_EVENTS.CREATE_ISSUE); onClose(); diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue index 160394142..a1a2f2e63 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/IssuesList.vue @@ -46,9 +46,9 @@ const loadLinkedIssues = async () => { } }; -const unlinkIssue = async linkId => { +const unlinkIssue = async (linkId, issueIdentifier) => { try { - await LinearAPI.unlinkIssue(linkId); + await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId); useTrack(LINEAR_EVENTS.UNLINK_ISSUE); linkedIssues.value = linkedIssues.value.filter( issue => issue.id !== linkId @@ -110,7 +110,7 @@ onMounted(() => { diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue index e9d1ca500..10978da39 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinearIssueItem.vue @@ -14,6 +14,8 @@ const props = defineProps({ const emit = defineEmits(['unlinkIssue']); +const { linkedIssue } = props; + const priorityMap = { 1: 'Urgent', 2: 'High', @@ -21,7 +23,7 @@ const priorityMap = { 4: 'Low', }; -const issue = computed(() => props.linkedIssue.issue); +const issue = computed(() => linkedIssue.issue); const assignee = computed(() => { const assigneeDetails = issue.value.assignee; @@ -37,7 +39,7 @@ const labels = computed(() => issue.value.labels?.nodes || []); const priorityLabel = computed(() => priorityMap[issue.value.priority]); const unlinkIssue = () => { - emit('unlinkIssue', props.linkedIssue.id); + emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier); }; diff --git a/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue b/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue index e1c8e2b6c..e3b69345a 100644 --- a/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue +++ b/app/javascript/dashboard/components/widgets/conversation/linear/LinkIssue.vue @@ -63,7 +63,7 @@ const onSearch = async value => { isFetching.value = true; const response = await LinearAPI.searchIssues(value); issues.value = response.data.map(issue => ({ - id: issue.id, + id: issue.identifier, name: `${issue.identifier} ${issue.title}`, icon: 'status', iconColor: issue.state.color, diff --git a/app/jobs/webhooks/telegram_events_job.rb b/app/jobs/webhooks/telegram_events_job.rb index 4f16c401d..ae5488203 100644 --- a/app/jobs/webhooks/telegram_events_job.rb +++ b/app/jobs/webhooks/telegram_events_job.rb @@ -35,7 +35,7 @@ class Webhooks::TelegramEventsJob < ApplicationJob def process_event_params(channel, params) return unless params[:telegram] - if params.dig(:telegram, :edited_message).present? + if params.dig(:telegram, :edited_message).present? || params.dig(:telegram, :edited_business_message).present? Telegram::UpdateMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform else Telegram::IncomingMessageService.new(inbox: channel.inbox, params: params['telegram'].with_indifferent_access).perform diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index 9e4b95c57..b00897614 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -69,6 +69,10 @@ class Channel::Telegram < ApplicationRecord message.conversation[:additional_attributes]['chat_id'] end + def business_connection_id(message) + message.conversation[:additional_attributes]['business_connection_id'] + end + def reply_to_message_id(message) message.content_attributes['in_reply_to_external_id'] end @@ -95,7 +99,13 @@ class Channel::Telegram < ApplicationRecord end def send_message(message) - response = message_request(chat_id(message), message.outgoing_content, reply_markup(message), reply_to_message_id(message)) + response = message_request( + chat_id(message), + message.outgoing_content, + reply_markup(message), + reply_to_message_id(message), + business_connection_id: business_connection_id(message) + ) process_error(message, response) response.parsed_response['result']['message_id'] if response.success? end @@ -131,9 +141,12 @@ class Channel::Telegram < ApplicationRecord stripped_html.gsub('<br>', "\n") end - def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil) + def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil) text_payload = convert_markdown_to_telegram_html(text) + business_body = {} + business_body[:business_connection_id] = business_connection_id if business_connection_id + HTTParty.post("#{telegram_api_url}/sendMessage", body: { chat_id: chat_id, @@ -141,6 +154,6 @@ class Channel::Telegram < ApplicationRecord reply_markup: reply_markup, parse_mode: 'HTML', reply_to_message_id: reply_to_message_id - }) + }.merge(business_body)) end end diff --git a/app/services/linear/activity_message_service.rb b/app/services/linear/activity_message_service.rb new file mode 100644 index 000000000..671031f37 --- /dev/null +++ b/app/services/linear/activity_message_service.rb @@ -0,0 +1,41 @@ +class Linear::ActivityMessageService + attr_reader :conversation, :action_type, :issue_data, :user + + def initialize(conversation:, action_type:, user:, issue_data: {}) + @conversation = conversation + @action_type = action_type + @issue_data = issue_data + @user = user + end + + def perform + return unless conversation && issue_data[:id] && user + + content = generate_activity_content + return unless content + + ::Conversations::ActivityMessageJob.perform_later(conversation, activity_message_params(content)) + end + + private + + def generate_activity_content + case action_type.to_sym + when :issue_created + I18n.t('conversations.activity.linear.issue_created', user_name: user.name, issue_id: issue_data[:id]) + when :issue_linked + I18n.t('conversations.activity.linear.issue_linked', user_name: user.name, issue_id: issue_data[:id]) + when :issue_unlinked + I18n.t('conversations.activity.linear.issue_unlinked', user_name: user.name, issue_id: issue_data[:id]) + end + end + + def activity_message_params(content) + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: content + } + end +end diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index 3a186676e..6eeb192f2 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -8,17 +8,25 @@ class Telegram::IncomingMessageService def perform # chatwoot doesn't support group conversations at the moment + transform_business_message! return unless private_message? set_contact update_contact_avatar set_conversation + # TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request. + # Otherwise, the client will see their messages as unread. + # Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations. + # We have two options: + # 1. Send the read request to Telegram here, immediately when the message is created. + # 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message. + # See: https://core.telegram.org/bots/api#readbusinessmessage @message = @conversation.messages.build( content: telegram_params_message_content, account_id: @inbox.account_id, inbox_id: @inbox.id, - message_type: :incoming, - sender: @contact, + message_type: message_type, + sender: message_sender, content_attributes: telegram_params_content_attributes, source_id: telegram_params_message_id.to_s ) @@ -36,6 +44,11 @@ class Telegram::IncomingMessageService contact_attributes: contact_attributes ).perform + # TODO: Should we update contact_attributes when the user changes their first or last name? + # In business chats, when our Telegram bot initiates the conversation, + # the message does not include a language code. + # This is critical for AI assistants and translation plugins. + @contact_inbox = contact_inbox @contact = contact_inbox.contact end @@ -89,10 +102,19 @@ class Telegram::IncomingMessageService def conversation_additional_attributes { - chat_id: telegram_params_chat_id + chat_id: telegram_params_chat_id, + business_connection_id: telegram_params_business_connection_id } end + def message_type + business_message_outgoing? ? :outgoing : :incoming + end + + def message_sender + business_message_outgoing? ? nil : @contact + end + def file_content_type return :image if image_message? return :audio if audio_message? @@ -191,4 +213,8 @@ class Telegram::IncomingMessageService params[:message][:video].presence || params[:message][:video_note].presence end + + def transform_business_message! + params[:message] = params[:business_message] if params[:business_message] && !params[:message] + end end diff --git a/app/services/telegram/param_helpers.rb b/app/services/telegram/param_helpers.rb index 6201fa0f3..7bbec9264 100644 --- a/app/services/telegram/param_helpers.rb +++ b/app/services/telegram/param_helpers.rb @@ -13,6 +13,17 @@ module Telegram::ParamHelpers {} end + def business_message? + telegram_params_business_connection_id.present? + end + + # In business bot mode we will receive messages from our telegram. + # This is our messages posted via telegram client. + # Such messages should be outgoing (from us to client) + def business_message_outgoing? + business_message? && telegram_params_base_object[:chat][:id] != telegram_params_base_object[:from][:id] + end + def message_params? params[:message].present? end @@ -29,24 +40,34 @@ module Telegram::ParamHelpers end end + def contact_params + if business_message_outgoing? + telegram_params_base_object[:chat] + else + telegram_params_base_object[:from] + end + end + def telegram_params_from_id + return telegram_params_base_object[:chat][:id] if business_message? + telegram_params_base_object[:from][:id] end def telegram_params_first_name - telegram_params_base_object[:from][:first_name] + contact_params[:first_name] end def telegram_params_last_name - telegram_params_base_object[:from][:last_name] + contact_params[:last_name] end def telegram_params_username - telegram_params_base_object[:from][:username] + contact_params[:username] end def telegram_params_language_code - telegram_params_base_object[:from][:language_code] + contact_params[:language_code] end def telegram_params_chat_id @@ -57,6 +78,14 @@ module Telegram::ParamHelpers end end + def telegram_params_business_connection_id + if callback_query_params? + params[:callback_query][:message][:business_connection_id] + else + telegram_params_base_object[:business_connection_id] + end + end + def telegram_params_message_content if callback_query_params? params[:callback_query][:data] diff --git a/app/services/telegram/send_attachments_service.rb b/app/services/telegram/send_attachments_service.rb index 2f69026b2..e214fe78f 100644 --- a/app/services/telegram/send_attachments_service.rb +++ b/app/services/telegram/send_attachments_service.rb @@ -71,6 +71,7 @@ class Telegram::SendAttachmentsService HTTParty.post("#{channel.telegram_api_url}/sendMediaGroup", body: { chat_id: chat_id, + **business_connection_body, media: attachments.map { |hash| hash.except(:attachment) }.to_json, reply_to_message_id: reply_to_message_id }) @@ -108,6 +109,7 @@ class Telegram::SendAttachmentsService HTTParty.post("#{channel.telegram_api_url}/sendDocument", body: { chat_id: chat_id, + **business_connection_body, document: file, reply_to_message_id: reply_to_message_id }, @@ -135,4 +137,14 @@ class Telegram::SendAttachmentsService def channel @channel ||= message.inbox.channel end + + def business_connection_id + @business_connection_id ||= channel.business_connection_id(message) + end + + def business_connection_body + body = {} + body[:business_connection_id] = business_connection_id if business_connection_id + body + end end diff --git a/app/services/telegram/update_message_service.rb b/app/services/telegram/update_message_service.rb index d76be5a3f..109d73ff8 100644 --- a/app/services/telegram/update_message_service.rb +++ b/app/services/telegram/update_message_service.rb @@ -5,6 +5,7 @@ class Telegram::UpdateMessageService pattr_initialize [:inbox!, :params!] def perform + transform_business_message! find_contact_inbox find_conversation find_message @@ -36,4 +37,8 @@ class Telegram::UpdateMessageService @message.update!(content: edited_message[:caption]) end end + + def transform_business_message! + params[:edited_message] = params[:edited_business_message] if params[:edited_business_message].present? + end end diff --git a/app/views/api/v2/accounts/reports/labels.csv.erb b/app/views/api/v2/accounts/reports/labels.csv.erb index fdf578415..a9d420155 100644 --- a/app/views/api/v2/accounts/reports/labels.csv.erb +++ b/app/views/api/v2/accounts/reports/labels.csv.erb @@ -4,7 +4,9 @@ I18n.t('reports.label_csv.label_title'), I18n.t('reports.label_csv.conversations_count'), I18n.t('reports.label_csv.avg_first_response_time'), - I18n.t('reports.label_csv.avg_resolution_time') + I18n.t('reports.label_csv.avg_resolution_time'), + I18n.t('reports.label_csv.avg_reply_time'), + I18n.t('reports.label_csv.resolution_count'), ] %> <%= CSVSafe.generate_line headers -%> diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 694cd240f..ae1162e05 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -1,18 +1,18 @@ - + <% if @portal.logo.present? %> - + <% end %> - <%= @portal.name %> + <%= @portal.name %> <%# Go to homepage link section %> <% if @portal.homepage_link %> - + <%= render partial: 'icons/redirect' %> @@ -99,11 +99,11 @@ - + <% if @portal.logo.present? %> - + <% end %> - <%= @portal.name %> + <%= @portal.name %> diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb index 7c5ba1f2a..f71574692 100644 --- a/app/views/public/api/v1/portals/_hero.html.erb +++ b/app/views/public/api/v1/portals/_hero.html.erb @@ -2,7 +2,7 @@ - <%= @portal.name %> + <%= @portal.name %> <%= portal.header_text %> diff --git a/app/views/public/api/v1/portals/_mobile_menu.html.erb b/app/views/public/api/v1/portals/_mobile_menu.html.erb index d4c88da32..fa66677bc 100644 --- a/app/views/public/api/v1/portals/_mobile_menu.html.erb +++ b/app/views/public/api/v1/portals/_mobile_menu.html.erb @@ -1,3 +1,4 @@ +<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %> @@ -46,10 +47,12 @@ - + <% if has_multiple_locales %> + + <% end %> - <% if @portal.config["allowed_locales"].length > 1 %> + <% if has_multiple_locales %> <%= I18n.t('public_portal.header.language', default: 'Language') %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 463aa713e..8b392f47f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -105,6 +105,8 @@ en: conversations_count: No. of conversations avg_first_response_time: Avg first response time avg_resolution_time: Avg resolution time + avg_reply_time: Avg reply time + resolution_count: Resolution Count team_csv: team_name: Team name conversations_count: Conversations count @@ -188,6 +190,10 @@ en: sla: added: '%{user_name} added SLA policy %{sla_name}' removed: '%{user_name} removed SLA policy %{sla_name}' + linear: + issue_created: 'Linear issue %{issue_id} was created by %{user_name}' + issue_linked: 'Linear issue %{issue_id} was linked by %{user_name}' + issue_unlinked: 'Linear issue %{issue_id} was unlinked by %{user_name}' csat: not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions' muted: '%{user_name} has muted the conversation' diff --git a/lib/integrations/linear/processor_service.rb b/lib/integrations/linear/processor_service.rb index 2dfae28dc..a53d17f4c 100644 --- a/lib/integrations/linear/processor_service.rb +++ b/lib/integrations/linear/processor_service.rb @@ -28,7 +28,8 @@ class Integrations::Linear::ProcessorService { data: { id: response['issueCreate']['issue']['id'], - title: response['issueCreate']['issue']['title'] } + title: response['issueCreate']['issue']['title'], + identifier: response['issueCreate']['issue']['identifier'] } } end diff --git a/lib/linear/mutations.rb b/lib/linear/mutations.rb index 6a9377e13..04774c705 100644 --- a/lib/linear/mutations.rb +++ b/lib/linear/mutations.rb @@ -25,6 +25,7 @@ module Linear::Mutations issue { id title + identifier } } } diff --git a/spec/builders/v2/reports/label_summary_builder_spec.rb b/spec/builders/v2/reports/label_summary_builder_spec.rb index 7a5a589dd..1560008a1 100644 --- a/spec/builders/v2/reports/label_summary_builder_spec.rb +++ b/spec/builders/v2/reports/label_summary_builder_spec.rb @@ -140,7 +140,7 @@ RSpec.describe V2::Reports::LabelSummaryBuilder do create(:reporting_event, account: account, conversation: conv, - name: 'reply', + name: 'reply_time', value: (15 + (idx * 5)) * 60, value_in_business_hours: (10 + (idx * 3)) * 60, created_at: Time.zone.today) 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 0f27e2bd2..851c5dbaf 100644 --- a/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/integrations/linear_controller_spec.rb @@ -93,6 +93,8 @@ RSpec.describe 'Linear Integration API', type: :request do end describe 'POST /api/v1/accounts/:account_id/integrations/linear/create_issue' do + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } let(:issue_params) do { team_id: 'team1', @@ -101,32 +103,56 @@ RSpec.describe 'Linear Integration API', type: :request do assignee_id: 'user1', priority: 'high', state_id: 'state1', - label_ids: ['label1'] + label_ids: ['label1'], + conversation_id: conversation.display_id } end context 'when it is an authenticated user' do context 'when the issue is created successfully' do - let(:created_issue) { { data: { 'id' => 'issue1', 'title' => 'Sample Issue' } } } + 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) + post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", params: issue_params, headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:ok) expect(response.body).to include('Sample Issue') 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) + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", + params: issue_params, + headers: agent.create_new_auth_token, + as: :json + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-123 was created by #{agent.name}" + }) + end end context 'when issue creation fails' do - it 'returns error message' 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') - post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", - params: issue_params, - headers: agent.create_new_auth_token, - as: :json + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/create_issue", + params: issue_params, + headers: agent.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + expect(response).to have_http_status(:unprocessable_entity) expect(response.body).to include('error message') end @@ -135,7 +161,7 @@ RSpec.describe 'Linear Integration API', type: :request do end describe 'POST /api/v1/accounts/:account_id/integrations/linear/link_issue' do - let(:issue_id) { 'issue1' } + let(:issue_id) { 'ENG-456' } let(:conversation) { create(:conversation, account: account) } let(:link) { "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}" } let(:title) { 'Sample Issue' } @@ -144,24 +170,38 @@ RSpec.describe 'Linear Integration API', type: :request do context 'when the issue is linked successfully' do let(:linked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } } - it 'returns the linked issue' do + 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) - post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", - params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, - headers: agent.create_new_auth_token, - as: :json + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", + params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, + headers: agent.create_new_auth_token, + as: :json + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-456 was linked by #{agent.name}" + }) + expect(response).to have_http_status(:ok) expect(response.body).to include('https://linear.app/issue1') end end context 'when issue linking fails' do - it 'returns error message' 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') - post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", - params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, - headers: agent.create_new_auth_token, - as: :json + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/link_issue", + params: { conversation_id: conversation.display_id, issue_id: issue_id, title: title }, + headers: agent.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + expect(response).to have_http_status(:unprocessable_entity) expect(response.body).to include('error message') end @@ -171,29 +211,45 @@ RSpec.describe 'Linear Integration API', type: :request do describe 'POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue' do let(:link_id) { 'attachment1' } + let(:issue_id) { 'ENG-789' } + let(:conversation) { create(:conversation, account: account) } context 'when it is an authenticated user' do context 'when the issue is unlinked successfully' do let(:unlinked_issue) { { data: { 'id' => 'issue1', 'link' => 'https://linear.app/issue1' } } } - it 'returns the unlinked issue' do + it 'returns the unlinked issue and creates activity message' do allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(unlinked_issue) - post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", - params: { link_id: link_id }, - headers: agent.create_new_auth_token, - as: :json + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", + params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-789 was unlinked by #{agent.name}" + }) + expect(response).to have_http_status(:ok) expect(response.body).to include('https://linear.app/issue1') end end context 'when issue unlinking fails' do - it 'returns error message' do + it 'returns error message and does not create activity message' do allow(processor_service).to receive(:unlink_issue).with(link_id).and_return(error: 'error message') - post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", - params: { link_id: link_id }, - headers: agent.create_new_auth_token, - as: :json + + expect do + post "/api/v1/accounts/#{account.id}/integrations/linear/unlink_issue", + params: { link_id: link_id, issue_id: issue_id, conversation_id: conversation.display_id }, + headers: agent.create_new_auth_token, + as: :json + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + expect(response).to have_http_status(:unprocessable_entity) expect(response.body).to include('error message') end diff --git a/spec/lib/integrations/linear/processor_service_spec.rb b/spec/lib/integrations/linear/processor_service_spec.rb index 807e93c71..8830e658e 100644 --- a/spec/lib/integrations/linear/processor_service_spec.rb +++ b/spec/lib/integrations/linear/processor_service_spec.rb @@ -82,15 +82,27 @@ describe Integrations::Linear::ProcessorService do end let(:issue_response) do { - 'issueCreate' => { 'issue' => { 'id' => 'issue1', 'title' => 'Issue title' } } + 'issueCreate' => { + 'issue' => { + 'id' => 'issue1', + 'title' => 'Issue title', + 'identifier' => 'ENG-123' + } + } } end context 'when Linear client returns valid data' do - it 'returns parsed issue data' do + it 'returns parsed issue data with identifier' do allow(linear_client).to receive(:create_issue).with(params).and_return(issue_response) result = service.create_issue(params) - expect(result).to eq({ data: { id: 'issue1', title: 'Issue title' } }) + expect(result).to eq({ + data: { + id: 'issue1', + title: 'Issue title', + identifier: 'ENG-123' + } + }) end end @@ -133,13 +145,13 @@ describe Integrations::Linear::ProcessorService do describe '#unlink_issue' do let(:link_id) { 'attachment1' } - let(:unlink_response) { { data: { link_id: link_id } } } + let(:linear_client_response) { { success: true } } context 'when Linear client returns valid data' do - it 'returns parsed unlink data' do - allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(unlink_response) + it 'returns unlink data with link_id' do + allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response) result = service.unlink_issue(link_id) - expect(result).to eq(unlink_response) + expect(result).to eq({ data: { link_id: link_id } }) end end @@ -207,4 +219,59 @@ describe Integrations::Linear::ProcessorService do end end end + + # Tests specifically for activity message integration + describe 'activity message data compatibility' do + let(:linear_client_response) { { success: true } } + + describe '#create_issue' do + it 'includes identifier field needed for activity messages' do + params = { title: 'Test Issue', team_id: 'team1' } + response = { + 'issueCreate' => { + 'issue' => { + 'id' => 'internal_id_123', + 'title' => 'Test Issue', + 'identifier' => 'ENG-456' + } + } + } + + allow(linear_client).to receive(:create_issue).with(params).and_return(response) + result = service.create_issue(params) + + expect(result[:data]).to have_key(:identifier) + expect(result[:data][:identifier]).to eq('ENG-456') + end + end + + describe '#link_issue' do + it 'returns issue_id in response for activity messages' do + link = 'https://example.com' + issue_id = 'ENG-789' + title = 'Test Issue' + response = { + 'attachmentLinkURL' => { + 'attachment' => { 'id' => 'attachment123' } + } + } + + allow(linear_client).to receive(:link_issue).with(link, issue_id, title).and_return(response) + result = service.link_issue(link, issue_id, title) + + expect(result[:data][:id]).to eq(issue_id) + end + end + + describe '#unlink_issue' do + it 'returns structured data for activity messages' do + link_id = 'attachment456' + + allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response) + result = service.unlink_issue(link_id) + + expect(result).to eq({ data: { link_id: link_id } }) + end + end + end end diff --git a/spec/models/channel/telegram_spec.rb b/spec/models/channel/telegram_spec.rb index 17aa848f6..dc401a165 100644 --- a/spec/models/channel/telegram_spec.rb +++ b/spec/models/channel/telegram_spec.rb @@ -114,6 +114,24 @@ RSpec.describe Channel::Telegram do expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') end + it 'sends message with business_connection_id' do + additional_attributes = { 'chat_id' => '123', 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' } + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: additional_attributes)) + + stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage") + .with( + body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id=&business_connection_id=eooW3KF5WB5HxTD7T826' + ) + .to_return( + status: 200, + body: { result: { message_id: 'telegram_123' } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') + end + it 'send text message failed' do message = create(:message, message_type: :outgoing, content: 'test', conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) diff --git a/spec/services/linear/activity_message_service_spec.rb b/spec/services/linear/activity_message_service_spec.rb new file mode 100644 index 000000000..4b51f6520 --- /dev/null +++ b/spec/services/linear/activity_message_service_spec.rb @@ -0,0 +1,174 @@ +require 'rails_helper' + +RSpec.describe Linear::ActivityMessageService, type: :service do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:user) { create(:user, account: account) } + + describe '#perform' do + context 'when action_type is issue_created' do + let(:service) do + described_class.new( + conversation: conversation, + action_type: :issue_created, + issue_data: { id: 'ENG-123' }, + user: user + ) + end + + it 'enqueues an activity message job' do + expect do + service.perform + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-123 was created by #{user.name}" + }) + end + + it 'does not enqueue job when issue data lacks id' do + service = described_class.new( + conversation: conversation, + action_type: :issue_created, + issue_data: { title: 'Some issue' }, + user: user + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + + it 'does not enqueue job when issue_data is empty' do + service = described_class.new( + conversation: conversation, + action_type: :issue_created, + issue_data: {}, + user: user + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + + it 'does not enqueue job when conversation is nil' do + service = described_class.new( + conversation: nil, + action_type: :issue_created, + issue_data: { id: 'ENG-123' }, + user: user + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + + it 'does not enqueue job when user is nil' do + service = described_class.new( + conversation: conversation, + action_type: :issue_created, + issue_data: { id: 'ENG-123' }, + user: nil + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + end + + context 'when action_type is issue_linked' do + let(:service) do + described_class.new( + conversation: conversation, + action_type: :issue_linked, + issue_data: { id: 'ENG-456' }, + user: user + ) + end + + it 'enqueues an activity message job' do + expect do + service.perform + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-456 was linked by #{user.name}" + }) + end + + it 'does not enqueue job when issue data lacks id' do + service = described_class.new( + conversation: conversation, + action_type: :issue_linked, + issue_data: { title: 'Some issue' }, + user: user + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + end + + context 'when action_type is issue_unlinked' do + let(:service) do + described_class.new( + conversation: conversation, + action_type: :issue_unlinked, + issue_data: { id: 'ENG-789' }, + user: user + ) + end + + it 'enqueues an activity message job' do + expect do + service.perform + end.to have_enqueued_job(Conversations::ActivityMessageJob) + .with(conversation, { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: "Linear issue ENG-789 was unlinked by #{user.name}" + }) + end + + it 'does not enqueue job when issue data lacks id' do + service = described_class.new( + conversation: conversation, + action_type: :issue_unlinked, + issue_data: { title: 'Some issue' }, + user: user + ) + + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + end + + context 'when action_type is unknown' do + let(:service) do + described_class.new( + conversation: conversation, + action_type: :unknown_action, + issue_data: { id: 'ENG-999' }, + user: user + ) + end + + it 'does not enqueue job for unknown action types' do + expect do + service.perform + end.not_to have_enqueued_job(Conversations::ActivityMessageJob) + end + end + end +end \ No newline at end of file diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 78a8e9d7c..ef43dbe24 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -89,6 +89,61 @@ describe Telegram::IncomingMessageService do end end + context 'when business connection messages' do + subject do + described_class.new(inbox: telegram_channel.inbox, params: params).perform + end + + let(:business_message_params) { message_params.merge('business_connection_id' => 'eooW3KF5WB5HxTD7T826') } + let(:params) do + { + 'update_id' => 2_342_342_343_242, + 'business_message' => { 'text' => 'test' }.deep_merge(business_message_params) + }.with_indifferent_access + end + + it 'creates appropriate conversations, message and contacts' do + subject + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) + contact = Contact.all.first + expect(contact.name).to eq('Sojan Jose') + expect(contact.additional_attributes['language_code']).to eq('en') + message = telegram_channel.inbox.messages.first + expect(message.content).to eq('test') + expect(message.message_type).to eq('incoming') + expect(message.sender).to eq(contact) + end + + context 'when sender is your business account' do + let(:business_message_params) do + message_params.merge( + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826', + 'from' => { + 'id' => 42, 'is_bot' => false, 'first_name' => 'John', 'last_name' => 'Doe', 'username' => 'johndoe', 'language_code' => 'en' + } + ) + end + + it 'creates appropriate conversations, message and contacts' do + subject + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(telegram_channel.inbox.conversations.last.additional_attributes).to include({ 'chat_id' => 23, + 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }) + contact = Contact.all.first + expect(contact.name).to eq('Sojan Jose') + # TODO: The language code is not present when we send the first message to the client. + # Should we update it when the user replies? + expect(contact.additional_attributes['language_code']).to be_nil + message = telegram_channel.inbox.messages.first + expect(message.content).to eq('test') + expect(message.message_type).to eq('outgoing') + expect(message.sender).to be_nil + end + end + end + context 'when valid audio messages params' do it 'creates appropriate conversations, message and contacts' do allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-assets.local/sample.mp3') diff --git a/spec/services/telegram/send_attachments_service_spec.rb b/spec/services/telegram/send_attachments_service_spec.rb index 2159acddd..f9c625780 100644 --- a/spec/services/telegram/send_attachments_service_spec.rb +++ b/spec/services/telegram/send_attachments_service_spec.rb @@ -40,6 +40,22 @@ RSpec.describe Telegram::SendAttachmentsService do end end + context 'when this is business chat' do + before { allow(channel).to receive(:business_connection_id).and_return('eooW3KF5WB5HxTD7T826') } + + it 'sends all types of attachments in seperate groups and returns the last successful message ID from the batch' do + attach_files(message) + service.perform + expect(a_request(:post, "#{telegram_api_url}/sendMediaGroup") + .with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m }) + .to have_been_made.times(2) + + expect(a_request(:post, "#{telegram_api_url}/sendDocument") + .with { |req| req.body =~ /business_connection_id.+eooW3KF5WB5HxTD7T826/m }) + .to have_been_made.once + end + end + context 'when all attachments are photo and video' do before do 2.times { attach_file_to_message(message, 'image', 'sample.png', 'image/png') } diff --git a/spec/services/telegram/update_message_service_spec.rb b/spec/services/telegram/update_message_service_spec.rb index 5c00c9009..33a279796 100644 --- a/spec/services/telegram/update_message_service_spec.rb +++ b/spec/services/telegram/update_message_service_spec.rb @@ -53,6 +53,24 @@ describe Telegram::UpdateMessageService do described_class.new(inbox: telegram_channel.inbox, params: caption_update_params.with_indifferent_access).perform expect(message.reload.content).to eq('updated caption') end + + context 'when business message' do + let(:text_update_params) do + { + 'update_id': 1, + 'edited_business_message': common_message_params.merge( + 'message_id': 48, + 'text': 'updated message' + ) + } + end + + it 'updates the message text when text is present' do + message = create(:message, conversation: conversation, source_id: text_update_params[:edited_business_message][:message_id]) + described_class.new(inbox: telegram_channel.inbox, params: text_update_params.with_indifferent_access).perform + expect(message.reload.content).to eq('updated message') + end + end end context 'when invalid update message params' do