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 @@