From c0d9533b3a3a5bfb07584ff23cc68290ff939021 Mon Sep 17 00:00:00 2001 From: Uchkun Rakhimov Date: Wed, 11 Jun 2025 02:31:28 +0500 Subject: [PATCH 01/22] feat: add support for telegram circular video messages (#11504) This PR adds support for handling Telegram's video_note messages (circular video messages), which are commonly used for short voice-style video replies in messaging. Fixes: #11503 Co-authored-by: Sojan Jose --- .../telegram/incoming_message_service.rb | 23 ++++++++++++--- .../telegram/incoming_message_service_spec.rb | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index e337a03f6..3a186676e 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -94,13 +94,25 @@ class Telegram::IncomingMessageService end def file_content_type - return :image if params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present? - return :audio if params[:message][:voice].present? || params[:message][:audio].present? - return :video if params[:message][:video].present? + return :image if image_message? + return :audio if audio_message? + return :video if video_message? file_type(params[:message][:document][:mime_type]) end + def image_message? + params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present? + end + + def audio_message? + params[:message][:voice].present? || params[:message][:audio].present? + end + + def video_message? + params[:message][:video].present? || params[:message][:video_note].present? + end + def attach_files return unless file @@ -174,6 +186,9 @@ class Telegram::IncomingMessageService end def visual_media_params - params[:message][:photo].presence&.last || params.dig(:message, :sticker, :thumb).presence || params[:message][:video].presence + params[:message][:photo].presence&.last || + params.dig(:message, :sticker, :thumb).presence || + params[:message][:video].presence || + params[:message][:video_note].presence end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 0702baa32..78a8e9d7c 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -179,6 +179,35 @@ describe Telegram::IncomingMessageService do end end + context 'when valid video_note 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.mov') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'video_note' => { + 'duration' => 3, + 'length' => 240, + 'thumb' => { + 'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE', + 'file_unique_id' => 'AQADqwMAAp3x8FZy', + 'file_size' => 11_462, + 'width' => 240, + 'height' => 240 + }, + 'file_id' => 'DQACAgUAAxkBAAIBY2FdJlhf8PC2E3IalXSvXWO5m8GBAALJAwACwqHgVhb0truM0uhwIQQ', + 'file_unique_id' => 'AgADyQMAAsKh4FY', + 'file_size' => 132_446 + } + }.merge(message_params) + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') + end + end + context 'when valid voice attachment 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.ogg') From 35f06f30e7bbcddc4456f35e600376d80bb2b5c8 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 11 Jun 2025 14:35:46 +0530 Subject: [PATCH 02/22] feat: label reports overview (#11194) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth --- .../v2/reports/label_summary_builder.rb | 101 ++++++ .../v2/accounts/summary_reports_controller.rb | 6 +- app/helpers/api/v2/accounts/reports_helper.rb | 10 +- .../dashboard/api/summaryReports.js | 10 + .../components-next/sidebar/Sidebar.vue | 2 +- .../dashboard/i18n/locale/en/report.json | 2 + .../settings/reports/LabelReportsIndex.vue | 35 ++ .../settings/reports/LabelReportsShow.vue | 31 ++ .../reports/components/SummaryReports.vue | 5 +- .../settings/reports/reports.routes.js | 18 + .../dashboard/store/modules/labels.js | 3 + .../modules/specs/summaryReports.spec.js | 40 +++ .../dashboard/store/modules/summaryReports.js | 17 + config/routes.rb | 1 + lib/seeders/reports/conversation_creator.rb | 105 ++++++ lib/seeders/reports/message_creator.rb | 141 ++++++++ lib/seeders/reports/report_data_seeder.rb | 234 +++++++++++++ lib/tasks/seed_reports_data.rake | 24 ++ .../v2/reports/label_summary_builder_spec.rb | 317 ++++++++++++++++++ 19 files changed, 1095 insertions(+), 7 deletions(-) create mode 100644 app/builders/v2/reports/label_summary_builder.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue create mode 100644 lib/seeders/reports/conversation_creator.rb create mode 100644 lib/seeders/reports/message_creator.rb create mode 100644 lib/seeders/reports/report_data_seeder.rb create mode 100644 lib/tasks/seed_reports_data.rake create mode 100644 spec/builders/v2/reports/label_summary_builder_spec.rb diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..abc68b26b --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,101 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + 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) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + fetch_counts(conversation_filter.merge(status: :resolved)) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + end +end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js index f772ef86f..fad26bf6f 100644 --- a/app/javascript/dashboard/api/summaryReports.js +++ b/app/javascript/dashboard/api/summaryReports.js @@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient { }, }); } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } } export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 5d7aac3c3..171f4a4d8 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -87,7 +87,7 @@ const newReportRoutes = () => [ { name: 'Reports Label', label: t('SIDEBAR.REPORTS_LABEL'), - to: accountScopedRoute('label_reports'), + to: accountScopedRoute('label_reports_index'), }, { name: 'Reports Inbox', diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 294ca2e7b..7c42fdfba 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -193,6 +193,7 @@ }, "LABEL_REPORTS": { "HEADER": "Labels Overview", + "DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.", "LOADING_CHART": "Loading chart data...", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "DOWNLOAD_LABEL_REPORTS": "Download label reports", @@ -559,6 +560,7 @@ "INBOX": "Inbox", "AGENT": "Agent", "TEAM": "Team", + "LABEL": "Label", "AVG_RESOLUTION_TIME": "Avg. Resolution Time", "AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time", "AVG_REPLY_TIME": "Avg. Customer Waiting Time", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue new file mode 100644 index 000000000..956b30974 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue @@ -0,0 +1,35 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue new file mode 100644 index 000000000..678028410 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue @@ -0,0 +1,31 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue index 4c0c4fe64..7e0b9be8f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue @@ -108,7 +108,8 @@ const tableData = computed(() => } = rowMetrics; return { id: row.id, - name: row.name, + // we fallback on title, label for instance does not have a name property + name: row.name ?? row.title, type: props.type, conversationsCount: renderCount(conversationsCount), avgFirstResponseTime: renderAvgTime(avgFirstResponseTime), @@ -177,7 +178,7 @@ defineExpose({ downloadReports });