diff --git a/.circleci/config.yml b/.circleci/config.yml index 09bd5191d..c0320652b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 16 + parallelism: 20 steps: - checkout - node/install: diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb index 818c217ca..8045e84c9 100644 --- a/app/builders/messages/instagram/base_message_builder.rb +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -158,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: message_type, + status: @outgoing_echo ? :delivered : :sent, source_id: message_identifier, content: message_content, sender: @outgoing_echo ? nil : contact, @@ -166,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil } } + params[:content_attributes][:external_echo] = true if @outgoing_echo params[:content_attributes][:is_unsupported] = true if message_is_unsupported? params end diff --git a/app/builders/v2/reports/first_response_time_distribution_builder.rb b/app/builders/v2/reports/first_response_time_distribution_builder.rb new file mode 100644 index 000000000..971542596 --- /dev/null +++ b/app/builders/v2/reports/first_response_time_distribution_builder.rb @@ -0,0 +1,68 @@ +class V2::Reports::FirstResponseTimeDistributionBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + build_distribution + end + + private + + def build_distribution + results = fetch_aggregated_counts + map_to_channel_types(results) + end + + def fetch_aggregated_counts + ReportingEvent + .where(account_id: account.id, name: 'first_response') + .where(range_condition) + .group(:inbox_id) + .select( + :inbox_id, + bucket_case_statements + ) + end + + def bucket_case_statements + <<~SQL.squish + COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h, + COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h, + COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h, + COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h, + COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus + SQL + end + + def range_condition + range.present? ? { created_at: range } : {} + end + + def inbox_channel_types + @inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h + end + + def map_to_channel_types(results) + results.each_with_object({}) do |row, hash| + channel_type = inbox_channel_types[row.inbox_id] + next unless channel_type + + hash[channel_type] ||= empty_buckets + hash[channel_type]['0-1h'] += row.bucket_0_1h + hash[channel_type]['1-4h'] += row.bucket_1_4h + hash[channel_type]['4-8h'] += row.bucket_4_8h + hash[channel_type]['8-24h'] += row.bucket_8_24h + hash[channel_type]['24h+'] += row.bucket_24h_plus + end + end + + def empty_buckets + { '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 } + end +end diff --git a/app/builders/v2/reports/inbox_label_matrix_builder.rb b/app/builders/v2/reports/inbox_label_matrix_builder.rb new file mode 100644 index 000000000..c3715019d --- /dev/null +++ b/app/builders/v2/reports/inbox_label_matrix_builder.rb @@ -0,0 +1,65 @@ +class V2::Reports::InboxLabelMatrixBuilder + include DateRangeHelper + + attr_reader :account, :params + + def initialize(account:, params:) + @account = account + @params = params + end + + def build + { + inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } }, + labels: filtered_labels.map { |label| { id: label.id, title: label.title } }, + matrix: build_matrix + } + end + + private + + def filtered_inboxes + @filtered_inboxes ||= begin + inboxes = account.inboxes + inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present? + inboxes.order(:name).to_a + end + end + + def filtered_labels + @filtered_labels ||= begin + labels = account.labels + labels = labels.where(id: params[:label_ids]) if params[:label_ids].present? + labels.order(:title).to_a + end + end + + def conversation_filter + filter = { account_id: account.id } + filter[:created_at] = range if range.present? + filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present? + filter + end + + def fetch_grouped_counts + label_names = filtered_labels.map(&:title) + return {} if label_names.empty? + + 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) + .where(tags: { name: label_names }) + .group('conversations.inbox_id', 'tags.name') + .count + end + + def build_matrix + counts = fetch_grouped_counts + filtered_inboxes.map do |inbox| + filtered_labels.map do |label| + counts[[inbox.id, label.title]] || 0 + end + end + end +end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e2b930ac9..b3151c8fa 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def transcript render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank? + return head :too_many_requests unless @conversation.account.within_email_rate_limit? ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later + @conversation.account.increment_email_sent_count head :ok end diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index fe5facc1a..96c15fde2 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController end def transcript - if conversation.present? && conversation.contact.present? && conversation.contact.email.present? - ConversationReplyMailer.with(account: conversation.account).conversation_transcript( - conversation, - conversation.contact.email - )&.deliver_later - end + return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit? + + send_transcript_email head :ok end @@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController private + def send_transcript_email + return if conversation.contact&.email.blank? + + ConversationReplyMailer.with(account: conversation.account).conversation_transcript( + conversation, + conversation.contact.email + )&.deliver_later + conversation.account.increment_email_sent_count + end + def trigger_typing_event(event) Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact) end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 714aeb0c9..ddd629048 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -62,6 +62,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: bot_metrics end + def inbox_label_matrix + builder = V2::Reports::InboxLabelMatrixBuilder.new( + account: Current.account, + params: inbox_label_matrix_params + ) + render json: builder.build + end + + def first_response_time_distribution + builder = V2::Reports::FirstResponseTimeDistributionBuilder.new( + account: Current.account, + params: first_response_time_distribution_params + ) + render json: builder.build + end + private def generate_csv(filename, template) @@ -139,4 +155,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController def conversation_metrics V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics end + + def inbox_label_matrix_params + { + since: params[:since], + until: params[:until], + inbox_ids: params[:inbox_ids], + label_ids: params[:label_ids] + } + end + + def first_response_time_distribution_params + { + since: params[:since], + until: params[:until] + } + end end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index b910a9c9a..67d58aef1 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -42,7 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT], 'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET], 'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET], - 'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'], + 'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS], 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index c4ae45fef..0f6ab85a8 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue'; import { useTimeoutFn } from '@vueuse/core'; import { provideMessageContext } from './provider.js'; import { useTrack } from 'dashboard/composables'; +import { useMapGetter } from 'dashboard/composables/store'; import { emitter } from 'shared/helpers/mitt'; import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; import { LocalStorage } from 'shared/helpers/localStorage'; import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; +import { getInboxIconByType } from 'dashboard/helper/inbox'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { MESSAGE_TYPES, @@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false); const showContextMenu = ref(false); const { t } = useI18n(); const route = useRoute(); +const inboxGetter = useMapGetter('inboxes/getInbox'); +const inbox = computed(() => inboxGetter.value(props.inboxId) || {}); /** * Computes the message variant based on props @@ -162,6 +166,10 @@ const variant = computed(() => { if (props.contentAttributes?.isUnsupported) return MESSAGE_VARIANTS.UNSUPPORTED; + if (props.contentAttributes?.externalEcho) { + return MESSAGE_VARIANTS.AGENT; + } + const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT; if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) { return MESSAGE_VARIANTS.BOT; @@ -424,6 +432,18 @@ function handleReplyTo() { } const avatarInfo = computed(() => { + if (props.contentAttributes?.externalEcho) { + const { name, avatar_url, channel_type, medium } = inbox.value; + const iconName = avatar_url + ? null + : getInboxIconByType(channel_type, medium); + return { + name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'), + src: avatar_url || '', + iconName, + }; + } + // If no sender, return bot info if (!props.sender) { return { @@ -451,6 +471,9 @@ const avatarInfo = computed(() => { }); const avatarTooltip = computed(() => { + if (props.contentAttributes?.externalEcho) { + return t('CONVERSATION.NATIVE_APP_ADVISORY'); + } if (avatarInfo.value.name === '') return ''; return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`; }); @@ -484,7 +507,7 @@ provideMessageContext({
- - {{ replyToPreview }} - +
{ 'resolve_conversation', 'remove_assigned_team', 'open_conversation', + 'pending_conversation', ]; if ( diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 43245a1d5..341027299 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -150,7 +150,8 @@ "ADD_PRIVATE_NOTE": "Add a Private Note", "CHANGE_PRIORITY": "Change Priority", "ADD_SLA": "Add SLA", - "OPEN_CONVERSATION": "Open conversation" + "OPEN_CONVERSATION": "Open conversation", + "PENDING_CONVERSATION": "Mark conversation as pending" }, "MESSAGE_TYPES": { "INCOMING": "Incoming Message", diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 896ccaab8..ded12a5c2 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -253,6 +253,8 @@ "MESSAGE_ERROR": "Unable to send this message, please try again later", "SENT_BY": "Sent by:", "BOT": "Bot", + "NATIVE_APP": "Native app", + "NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.", "SEND_FAILED": "Couldn't send message! Try again", "TRY_AGAIN": "retry", "ASSIGNMENT": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index bc767040b..3acca3e2e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -116,6 +116,10 @@ export const AUTOMATIONS = { key: 'open_conversation', name: 'OPEN_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -232,6 +236,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -360,6 +368,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'resolve_conversation', name: 'RESOLVE_CONVERSATION', @@ -482,6 +494,10 @@ export const AUTOMATIONS = { key: 'snooze_conversation', name: 'SNOOZE_CONVERSATION', }, + { + key: 'pending_conversation', + name: 'PENDING_CONVERSATION', + }, { key: 'send_webhook_event', name: 'SEND_WEBHOOK_EVENT', @@ -668,6 +684,11 @@ export const AUTOMATION_ACTION_TYPES = [ label: 'OPEN_CONVERSATION', inputType: null, }, + { + key: 'pending_conversation', + label: 'PENDING_CONVERSATION', + inputType: null, + }, { key: 'send_webhook_event', label: 'SEND_WEBHOOK_EVENT', diff --git a/app/jobs/conversation_reply_email_job.rb b/app/jobs/conversation_reply_email_job.rb index 5d186bf29..9d4c120c8 100644 --- a/app/jobs/conversation_reply_email_job.rb +++ b/app/jobs/conversation_reply_email_job.rb @@ -3,6 +3,7 @@ class ConversationReplyEmailJob < ApplicationJob def perform(conversation_id, last_queued_id) conversation = Conversation.find(conversation_id) + return unless conversation.account.active? if conversation.messages.incoming&.last&.content_type == 'incoming_email' ConversationReplyMailer.with(account: conversation.account).reply_without_summary(conversation, last_queued_id).deliver_later diff --git a/app/jobs/webhooks/tiktok_events_job.rb b/app/jobs/webhooks/tiktok_events_job.rb index 3461b93cf..eda67f87d 100644 --- a/app/jobs/webhooks/tiktok_events_job.rb +++ b/app/jobs/webhooks/tiktok_events_job.rb @@ -54,7 +54,7 @@ class Webhooks::TiktokEventsJob < MutexApplicationJob # Receive real-time notifications if you send a message to a user. def im_send_msg # This can be either an echo message or a message sent directly via tiktok application - ::Tiktok::MessageService.new(channel: channel, content: content).perform + ::Tiktok::MessageService.new(channel: channel, content: content, outgoing_echo: true).perform end # Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you. diff --git a/app/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb index cd2dad167..bf8fc5425 100644 --- a/app/jobs/webhooks/whatsapp_events_job.rb +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -9,6 +9,56 @@ class Webhooks::WhatsappEventsJob < ApplicationJob return end + if message_echo_event?(params) + handle_message_echo(channel, params) + else + handle_message_events(channel, params) + end + end + + # Detects if the webhook is an SMB message echo event (message sent from WhatsApp Business app) + # This is part of WhatsApp coexistence feature where businesses can respond from both + # Chatwoot and the WhatsApp Business app, with messages synced to Chatwoot. + # + # Regular message payload (field: "messages"): + # { + # "entry": [{ + # "changes": [{ + # "field": "messages", + # "value": { + # "contacts": [{ "wa_id": "919745786257", "profile": { "name": "Customer" } }], + # "messages": [{ "from": "919745786257", "id": "wamid...", "text": { "body": "Hello" } }] + # } + # }] + # }] + # } + # + # Echo message payload (field: "smb_message_echoes"): + # { + # "entry": [{ + # "changes": [{ + # "field": "smb_message_echoes", + # "value": { + # "message_echoes": [{ "from": "971545296927", "to": "919745786257", "id": "wamid...", "text": { "body": "Hi" } }] + # } + # }] + # }] + # } + # + # Key differences: + # - field: "smb_message_echoes" instead of "messages" + # - message_echoes[] instead of messages[] + # - "from" is the business number, "to" is the contact (reversed from regular messages) + # - No "contacts" array in echo payload + def message_echo_event?(params) + params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes' + end + + def handle_message_echo(channel, params) + Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform + end + + def handle_message_events(channel, params) case channel.provider when 'whatsapp_cloud' Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params).perform diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 8dbe67bf8..7fee05596 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -38,6 +38,7 @@ class ConversationReplyMailer < ApplicationMailer return unless smtp_config_set_or_development? init_conversation_attributes(message.conversation) + @message = message prepare_mail(true) end diff --git a/app/models/account.rb b/app/models/account.rb index df79ee6c1..fead5f0f7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -29,6 +29,7 @@ class Account < ApplicationRecord include Featurable include CacheKeys include CaptainFeaturable + include AccountEmailRateLimitable SETTINGS_PARAMS_SCHEMA = { 'type': 'object', diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index 9dc4d97eb..8162abb91 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -41,8 +41,8 @@ class AutomationRule < ApplicationRecord def actions_attributes %w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation - send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript - add_private_note].freeze + send_attachment change_status resolve_conversation open_conversation pending_conversation snooze_conversation change_priority + send_email_transcript add_private_note].freeze end def file_base_data diff --git a/app/models/concerns/account_email_rate_limitable.rb b/app/models/concerns/account_email_rate_limitable.rb new file mode 100644 index 000000000..e967408fc --- /dev/null +++ b/app/models/concerns/account_email_rate_limitable.rb @@ -0,0 +1,49 @@ +module AccountEmailRateLimitable + extend ActiveSupport::Concern + + OUTBOUND_EMAIL_TTL = 25.hours.to_i + EMAIL_LIMIT_CONFIG_KEY = 'ACCOUNT_EMAILS_LIMIT'.freeze + + def email_rate_limit + account_limit || global_limit || default_limit + end + + def emails_sent_today + Redis::Alfred.get(email_count_cache_key).to_i + end + + def within_email_rate_limit? + return true if emails_sent_today < email_rate_limit + + Rails.logger.warn("Account #{id} reached daily email rate limit of #{email_rate_limit}. Sent: #{emails_sent_today}") + false + end + + def increment_email_sent_count + Redis::Alfred.incr(email_count_cache_key).tap do |count| + Redis::Alfred.expire(email_count_cache_key, OUTBOUND_EMAIL_TTL) if count == 1 + end + end + + private + + def email_count_cache_key + @email_count_cache_key ||= format( + Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, + account_id: id, + date: Time.zone.today.to_s + ) + end + + def account_limit + self[:limits]&.dig('emails')&.to_i + end + + def global_limit + GlobalConfig.get(EMAIL_LIMIT_CONFIG_KEY)[EMAIL_LIMIT_CONFIG_KEY]&.to_i + end + + def default_limit + ChatwootApp.max_limit.to_i + end +end diff --git a/app/models/message.rb b/app/models/message.rb index 25a26afa9..20b9a756d 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -344,10 +344,11 @@ class Message < ApplicationRecord # if the sender is not a user, it's not a human response # if automation rule id is present, it's not a human response # if campaign id is present, it's not a human response + # external echo messages are responses sent from the native app (WhatsApp Business, Instagram) outgoing? && content_attributes['automation_rule_id'].blank? && additional_attributes['campaign_id'].blank? && - sender.is_a?(User) + (sender.is_a?(User) || content_attributes['external_echo'].present?) end def bot_response? diff --git a/app/services/action_service.rb b/app/services/action_service.rb index a50b11193..80caac392 100644 --- a/app/services/action_service.rb +++ b/app/services/action_service.rb @@ -22,6 +22,10 @@ class ActionService @conversation.open! end + def pending_conversation(_params) + @conversation.pending! + end + def change_status(status) @conversation.update!(status: status[0]) end diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb index 25a77b0d5..dd4f5006e 100644 --- a/app/services/messages/send_email_notification_service.rb +++ b/app/services/messages/send_email_notification_service.rb @@ -13,6 +13,7 @@ class Messages::SendEmailNotificationService return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i) ConversationReplyEmailJob.set(wait: 2.minutes).perform_later(conversation.id, message.id) + message.account.increment_email_sent_count end private @@ -20,6 +21,7 @@ class Messages::SendEmailNotificationService def should_send_email_notification? return false unless message.email_notifiable_message? return false if message.conversation.contact.email.blank? + return false unless message.account.within_email_rate_limit? email_reply_enabled? end diff --git a/app/services/notification/email_notification_service.rb b/app/services/notification/email_notification_service.rb index fbec8b86f..6fc68560b 100644 --- a/app/services/notification/email_notification_service.rb +++ b/app/services/notification/email_notification_service.rb @@ -7,15 +7,22 @@ class Notification::EmailNotificationService # don't send emails if user is not confirmed return if notification.user.confirmed_at.nil? return unless user_subscribed_to_notification? + return unless notification.account.within_email_rate_limit? - # TODO : Clean up whatever happening over here - # Segregate the mailers properly - AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification - .notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor).deliver_later + send_notification_email + notification.account.increment_email_sent_count end private + # TODO : Clean up whatever happening over here + # Segregate the mailers properly + def send_notification_email + AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send( + notification.notification_type.to_s, notification.primary_actor, notification.user, notification.secondary_actor + ).deliver_later + end + def user_subscribed_to_notification? notification_setting = notification.user.notification_settings.find_by(account_id: notification.account.id) return true if notification_setting.public_send("email_#{notification.notification_type}?") diff --git a/app/services/tiktok/message_service.rb b/app/services/tiktok/message_service.rb index c33f172d7..fcd613ec2 100644 --- a/app/services/tiktok/message_service.rb +++ b/app/services/tiktok/message_service.rb @@ -1,11 +1,10 @@ class Tiktok::MessageService include Tiktok::MessagingHelpers - pattr_initialize [:channel!, :content!] + pattr_initialize [:channel!, :content!, :outgoing_echo] def perform if outgoing_message? - # Skip processing echo messages message = find_message(tt_conversation_id, tt_message_id) return if message.present? end @@ -39,7 +38,7 @@ class Tiktok::MessageService updated_at: tt_message_time ) - message.sender = contact_inbox.contact if incoming_message? + message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo message.status = :delivered if outgoing_message? create_message_attachments(message) @@ -91,6 +90,7 @@ class Tiktok::MessageService attributes = {} attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id attributes[:is_unsupported] = true unless supported_message? + attributes[:external_echo] = true if outgoing_echo attributes end diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 55ce4e698..fa09a4b44 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -61,16 +61,36 @@ class Whatsapp::FacebookApiClient end def subscribe_waba_webhook(waba_id, callback_url, verify_token) + # Step 1: Subscribe app to WABA first (required before override) + # Meta requires the app to be subscribed before using override_callback_uri + # See: https://github.com/chatwoot/chatwoot/issues/13097 + subscribe_app_to_waba(waba_id) + + # Step 2: Override callback URL for this specific WABA + override_waba_callback(waba_id, callback_url, verify_token) + end + + def subscribe_app_to_waba(waba_id) + response = HTTParty.post( + "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", + headers: request_headers + ) + + handle_response(response, 'App subscription to WABA failed') + end + + def override_waba_callback(waba_id, callback_url, verify_token) response = HTTParty.post( "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps", headers: request_headers, body: { override_callback_uri: callback_url, - verify_token: verify_token + verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) - handle_response(response, 'Webhook subscription failed') + handle_response(response, 'Webhook callback override failed') end def unsubscribe_waba_webhook(waba_id) diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb index 315182fcd..40258e24e 100644 --- a/app/services/whatsapp/incoming_message_base_service.rb +++ b/app/services/whatsapp/incoming_message_base_service.rb @@ -4,18 +4,23 @@ class Whatsapp::IncomingMessageBaseService include ::Whatsapp::IncomingMessageServiceHelpers - pattr_initialize [:inbox!, :params!] + pattr_initialize [:inbox!, :params!, :outgoing_echo] def perform processed_params if processed_params.try(:[], :statuses).present? process_statuses - elsif processed_params.try(:[], :messages).present? + elsif messages_data.present? process_messages end end + # Returns messages array for both regular messages and echo events + def messages_data + @processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes) + end + private def process_messages @@ -26,7 +31,7 @@ class Whatsapp::IncomingMessageBaseService # Multiple webhook event can be received against the same message due to misconfigurations in the Meta # business manager account. While we have not found the core reason yet, the following line ensure that # there are no duplicate messages created. - return if find_message_by_source_id(@processed_params[:messages].first[:id]) || message_under_process? + return if find_message_by_source_id(messages_data.first[:id]) || message_under_process? cache_message_source_id_in_redis set_contact @@ -57,7 +62,7 @@ class Whatsapp::IncomingMessageBaseService end def create_messages - message = @processed_params[:messages].first + message = messages_data.first log_error(message) && return if error_webhook_event?(message) process_in_reply_to(message) @@ -67,20 +72,44 @@ class Whatsapp::IncomingMessageBaseService def create_contact_messages(message) message['contacts'].each do |contact| - create_message(contact) + # Pass source_id from parent message since contact objects don't have :id + create_message(contact, source_id: message[:id]) attach_contact(contact) @message.save! end end def create_regular_message(message) - create_message(message) + create_message(message, source_id: message[:id]) attach_files attach_location if message_type == 'location' @message.save! end def set_contact + if outgoing_echo + set_contact_from_echo + else + set_contact_from_message + end + end + + def set_contact_from_echo + # For echo messages, contact phone is in the 'to' field + phone_number = messages_data.first[:to] + waid = processed_waid(phone_number) + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: waid, + inbox: inbox, + contact_attributes: { name: "+#{phone_number}", phone_number: "+#{phone_number}" } + ).perform + + @contact_inbox = contact_inbox + @contact = contact_inbox.contact + end + + def set_contact_from_message contact_params = @processed_params[:contacts]&.first return if contact_params.blank? @@ -89,7 +118,7 @@ class Whatsapp::IncomingMessageBaseService contact_inbox = ::ContactInboxWithContactBuilder.new( source_id: waid, inbox: inbox, - contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{@processed_params[:messages].first[:from]}" } + contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{messages_data.first[:from]}" } ).perform @contact_inbox = contact_inbox @@ -115,7 +144,7 @@ class Whatsapp::IncomingMessageBaseService def attach_files return if %w[text button interactive location contacts].include?(message_type) - attachment_payload = @processed_params[:messages].first[message_type.to_sym] + attachment_payload = messages_data.first[message_type.to_sym] @message.content ||= attachment_payload[:caption] attachment_file = download_attachment_file(attachment_payload) @@ -133,7 +162,7 @@ class Whatsapp::IncomingMessageBaseService end def attach_location - location = @processed_params[:messages].first['location'] + location = messages_data.first['location'] location_name = location['name'] ? "#{location['name']}, #{location['address']}" : '' @message.attachments.new( account_id: @message.account_id, @@ -145,14 +174,17 @@ class Whatsapp::IncomingMessageBaseService ) end - def create_message(message) + def create_message(message, source_id: nil) @message = @conversation.messages.build( content: message_content(message), account_id: @inbox.account_id, inbox_id: @inbox.id, - message_type: :incoming, - sender: @contact, - source_id: message[:id].to_s, + message_type: outgoing_echo ? :outgoing : :incoming, + # Set status to :delivered for echo messages to prevent SendReplyJob from trying to send them + status: outgoing_echo ? :delivered : :sent, + sender: outgoing_echo ? nil : @contact, + source_id: (source_id || message[:id]).to_s, + content_attributes: outgoing_echo ? { external_echo: true } : {}, in_reply_to_external_id: @in_reply_to_external_id ) end @@ -189,7 +221,7 @@ class Whatsapp::IncomingMessageBaseService end def contact_name_matches_phone_number? - phone_number = "+#{@processed_params[:messages].first[:from]}" + phone_number = "+#{messages_data.first[:from]}" formatted_phone_number = TelephoneNumber.parse(phone_number).international_number @contact.name == phone_number || @contact.name == formatted_phone_number end diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index 46ad255aa..c803d61bd 100644 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -21,7 +21,7 @@ module Whatsapp::IncomingMessageServiceHelpers end def message_type - @processed_params[:messages].first[:type] + messages_data.first[:type] end def message_content(message) @@ -70,19 +70,19 @@ module Whatsapp::IncomingMessageServiceHelpers end def message_under_process? - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id]) Redis::Alfred.get(key) end def cache_message_source_id_in_redis - return if @processed_params.try(:[], :messages).blank? + return if messages_data.blank? - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id]) ::Redis::Alfred.setex(key, true) end def clear_message_source_id_from_redis - key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: @processed_params[:messages].first[:id]) + key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: messages_data.first[:id]) ::Redis::Alfred.delete(key) end end diff --git a/config/installation_config.yml b/config/installation_config.yml index 946b81e8e..34cb736bf 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -107,6 +107,16 @@ value: description: 'The support email address for your installation' locked: false +- name: ACCOUNT_EMAILS_LIMIT + display_title: 'Account Email Sending Limit (Daily)' + description: 'Maximum number of non-channel emails an account can send per day' + value: 100 + locked: false +- name: ACCOUNT_EMAILS_PLAN_LIMITS + display_title: 'Account Email Plan Limits (Daily)' + description: 'Per-plan daily email sending limits as JSON' + value: + type: code # ------- End of Email Related Config ------- # # ------- Facebook Channel Related Config ------- # diff --git a/config/routes.rb b/config/routes.rb index 83aed5b79..79e5edd23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -444,6 +444,8 @@ Rails.application.routes.draw do get :conversations_summary get :conversation_traffic get :bot_metrics + get :inbox_label_matrix + get :first_response_time_distribution end end resource :year_in_review, only: [:show] diff --git a/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb b/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb new file mode 100644 index 000000000..b7807901c --- /dev/null +++ b/db/migrate/20260130061021_add_index_to_reporting_events_for_response_distribution.rb @@ -0,0 +1,11 @@ +class AddIndexToReportingEventsForResponseDistribution < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :reporting_events, + [:account_id, :name, :inbox_id, :created_at], + name: 'index_reporting_events_for_response_distribution', + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 327110247..97d001b5c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_29_180004) do +ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1117,6 +1117,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_29_180004) do t.datetime "event_start_time", precision: nil t.datetime "event_end_time", precision: nil t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" + t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution" t.index ["account_id"], name: "index_reporting_events_on_account_id" t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id" t.index ["created_at"], name: "index_reporting_events_on_created_at" diff --git a/enterprise/app/fields/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb index b6aecd79f..2a46426b7 100644 --- a/enterprise/app/fields/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -2,6 +2,6 @@ require 'administrate/field/base' class AccountLimitsField < Administrate::Field::Base def to_s - data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json + data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil, emails: nil }.to_json end end diff --git a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb index ce03efa41..ee0803469 100644 --- a/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb +++ b/enterprise/app/models/enterprise/account/plan_usage_and_limits.rb @@ -1,4 +1,4 @@ -module Enterprise::Account::PlanUsageAndLimits +module Enterprise::Account::PlanUsageAndLimits # rubocop:disable Metrics/ModuleLength CAPTAIN_RESPONSES = 'captain_responses'.freeze CAPTAIN_DOCUMENTS = 'captain_documents'.freeze CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze @@ -32,6 +32,10 @@ module Enterprise::Account::PlanUsageAndLimits save end + def email_rate_limit + account_limit || plan_email_limit || global_limit || default_limit + end + def subscribed_features plan_features = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLAN_FEATURES')&.value return [] if plan_features.blank? @@ -68,6 +72,16 @@ module Enterprise::Account::PlanUsageAndLimits } end + def plan_email_limit + config = InstallationConfig.find_by(name: 'ACCOUNT_EMAILS_PLAN_LIMITS')&.value + return nil if config.blank? || plan_name.blank? + + parsed = config.is_a?(String) ? JSON.parse(config) : config + parsed[plan_name.downcase]&.to_i + rescue StandardError + nil + end + def default_captain_limits max_limits = { documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access zero_limits = { documents: 0, responses: 0 }.with_indifferent_access @@ -119,7 +133,8 @@ module Enterprise::Account::PlanUsageAndLimits 'inboxes' => { 'type': 'number' }, 'agents' => { 'type': 'number' }, 'captain_responses' => { 'type': 'number' }, - 'captain_documents' => { 'type': 'number' } + 'captain_documents' => { 'type': 'number' }, + 'emails' => { 'type': 'number' } }, 'required' => [], 'additionalProperties' => false diff --git a/enterprise/app/services/captain/tools/base_tool.rb b/enterprise/app/services/captain/tools/base_tool.rb index dbc2902d8..1ec4aaffc 100644 --- a/enterprise/app/services/captain/tools/base_tool.rb +++ b/enterprise/app/services/captain/tools/base_tool.rb @@ -1,4 +1,6 @@ class Captain::Tools::BaseTool < RubyLLM::Tool + prepend Captain::Tools::Instrumentation + attr_accessor :assistant def initialize(assistant, user: nil) diff --git a/enterprise/app/services/captain/tools/instrumentation.rb b/enterprise/app/services/captain/tools/instrumentation.rb new file mode 100644 index 000000000..2288b239e --- /dev/null +++ b/enterprise/app/services/captain/tools/instrumentation.rb @@ -0,0 +1,10 @@ +module Captain::Tools::Instrumentation + extend ActiveSupport::Concern + include Integrations::LlmInstrumentation + + def execute(**args) + instrument_tool_call(name, args) do + super + end + end +end diff --git a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb new file mode 100644 index 000000000..d2c1df42f --- /dev/null +++ b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb @@ -0,0 +1,42 @@ +class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool + prepend Captain::Tools::Instrumentation + + description 'Search and retrieve documentation/FAQs from knowledge base' + + param :query, desc: 'Search Query', required: true + + def initialize(account:, assistant: nil) + @account = account + @assistant = assistant + super() + end + + def name + 'search_documentation' + end + + def execute(query:) + Rails.logger.info { "#{self.class.name}: #{query}" } + + responses = search_responses(query) + return 'No FAQs found for the given query' if responses.empty? + + responses.map { |response| format_response(response) }.join + end + + private + + def search_responses(query) + if @assistant.present? + @assistant.responses.approved.search(query, account_id: @account.id) + else + @account.captain_assistant_responses.approved.search(query, account_id: @account.id) + end + end + + def format_response(response) + result = "\nQuestion: #{response.question}\nAnswer: #{response.answer}\n" + result += "Source: #{response.documentable.external_link}\n" if response.documentable.present? && response.documentable.try(:external_link) + result + end +end diff --git a/enterprise/lib/enterprise/captain/reply_suggestion_service.rb b/enterprise/lib/enterprise/captain/reply_suggestion_service.rb new file mode 100644 index 000000000..503dd095a --- /dev/null +++ b/enterprise/lib/enterprise/captain/reply_suggestion_service.rb @@ -0,0 +1,24 @@ +module Enterprise::Captain::ReplySuggestionService + def make_api_call(model:, messages:, tools: []) + return super unless use_search_tool? + + super(model: model, messages: messages, tools: [build_search_tool]) + end + + private + + def use_search_tool? + ChatwootApp.chatwoot_cloud? || ChatwootApp.self_hosted_enterprise? + end + + def prompt_variables + return super unless use_search_tool? + + super.merge('has_search_tool' => true) + end + + def build_search_tool + assistant = conversation&.inbox&.captain_assistant + Captain::Tools::SearchReplyDocumentationService.new(account: account, assistant: assistant) + end +end diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index b0cf7d240..7b84a879d 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -1,5 +1,6 @@ class Captain::BaseTaskService include Integrations::LlmInstrumentation + include Captain::ToolInstrumentation # gpt-4o-mini supports 128,000 tokens # 1 token is approx 4 characters @@ -35,44 +36,52 @@ class Captain::BaseTaskService "#{endpoint}/v1" end - def make_api_call(model:, messages:) + def make_api_call(model:, messages:, tools: []) # Community edition prerequisite checks # Enterprise module handles these with more specific error messages (cloud vs self-hosted) return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled? return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured? instrumentation_params = build_instrumentation_params(model, messages) + instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call - response = instrument_llm_call(instrumentation_params) do - execute_ruby_llm_request(model: model, messages: messages) + response = send(instrumentation_method, instrumentation_params) do + execute_ruby_llm_request(model: model, messages: messages, tools: tools) end - # Build follow-up context for client-side refinement, when applicable - if build_follow_up_context? && response[:message].present? - response.merge(follow_up_context: build_follow_up_context(messages, response)) - else - response - end + return response unless build_follow_up_context? && response[:message].present? + + response.merge(follow_up_context: build_follow_up_context(messages, response)) end - def execute_ruby_llm_request(model:, messages:) + def execute_ruby_llm_request(model:, messages:, tools: []) Llm::Config.with_api_key(api_key, api_base: api_base) do |context| - chat = context.chat(model: model) - system_msg = messages.find { |m| m[:role] == 'system' } - chat.with_instructions(system_msg[:content]) if system_msg + chat = build_chat(context, model: model, messages: messages, tools: tools) conversation_messages = messages.reject { |m| m[:role] == 'system' } return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty? add_messages_if_needed(chat, conversation_messages) - response = chat.ask(conversation_messages.last[:content]) - build_ruby_llm_response(response, messages) + build_ruby_llm_response(chat.ask(conversation_messages.last[:content]), messages) end rescue StandardError => e ChatwootExceptionTracker.new(e, account: account).capture_exception { error: e.message, request_messages: messages } end + def build_chat(context, model:, messages:, tools: []) + chat = context.chat(model: model) + system_msg = messages.find { |m| m[:role] == 'system' } + chat.with_instructions(system_msg[:content]) if system_msg + + if tools.any? + tools.each { |tool| chat = chat.with_tool(tool) } + chat.on_end_message { |message| record_generation(chat, message, model) } + end + + chat + end + def add_messages_if_needed(chat, conversation_messages) return if conversation_messages.length == 1 @@ -177,5 +186,4 @@ class Captain::BaseTaskService user_msg ? user_msg[:content] : nil end end - Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService') diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb index 8582258a8..2daf0615c 100644 --- a/lib/captain/reply_suggestion_service.rb +++ b/lib/captain/reply_suggestion_service.rb @@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService 'reply_suggestion' end end + +Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService') diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb new file mode 100644 index 000000000..a2bacce1a --- /dev/null +++ b/lib/captain/tool_instrumentation.rb @@ -0,0 +1,48 @@ +module Captain::ToolInstrumentation + extend ActiveSupport::Concern + + private + + # Custom instrumentation for tool flows - outputs just the message (not full hash) + def instrument_tool_session(params) + return yield unless ChatwootApp.otel_enabled? + + response = nil + executed = false + tracer.in_span(params[:span_name]) do |span| + span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id] + span.set_attribute('langfuse.tags', [params[:feature_name]].to_json) + span.set_attribute('langfuse.observation.input', params[:messages].to_json) + + response = yield + executed = true + + # Output just the message for cleaner Langfuse display + span.set_attribute('langfuse.observation.output', response[:message] || response.to_json) + end + response + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: account).capture_exception + executed ? response : yield + end + + def record_generation(chat, message, model) + return unless ChatwootApp.otel_enabled? + return unless message.respond_to?(:role) && message.role.to_s == 'assistant' + + tracer.in_span("llm.#{event_name}.generation") do |span| + span.set_attribute('gen_ai.system', 'openai') + span.set_attribute('gen_ai.request.model', model) + span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens) + span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens) + span.set_attribute('langfuse.observation.input', format_chat_messages(chat)) + span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content) + end + rescue StandardError => e + Rails.logger.warn "Failed to record generation: #{e.message}" + end + + def format_chat_messages(chat) + chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json + end +end diff --git a/lib/chatwoot_app.rb b/lib/chatwoot_app.rb index 3afb7579e..c0aa41e1a 100644 --- a/lib/chatwoot_app.rb +++ b/lib/chatwoot_app.rb @@ -21,6 +21,10 @@ module ChatwootApp enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' end + def self.self_hosted_enterprise? + enterprise? && !chatwoot_cloud? && GlobalConfig.get_value('INSTALLATION_PRICING_PLAN') == 'enterprise' + end + def self.custom? @custom ||= root.join('custom').exist? end diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid index 19db51a05..f9b95dbdf 100644 --- a/lib/integrations/openai/openai_prompts/reply.liquid +++ b/lib/integrations/openai/openai_prompts/reply.liquid @@ -31,5 +31,10 @@ General guidelines: - Move the conversation forward - Do not invent product details, policies, or links that weren't mentioned - Reply in the customer's language +{% if has_search_tool %} + +**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. +**Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation. +{% endif %} Output only the reply. diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 973c2b188..8c9361ab5 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -49,4 +49,7 @@ module Redis::RedisKeys # Track conversation assignments to agents for rate limiting ASSIGNMENT_KEY = 'ASSIGNMENT::%d::AGENT::%d::CONVERSATION::%d'.freeze ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%d::AGENT::%d::*'.freeze + + ## Account Email Rate Limiting + ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze end diff --git a/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb new file mode 100644 index 000000000..de1dc4a53 --- /dev/null +++ b/spec/builders/v2/reports/first_response_time_distribution_builder_spec.rb @@ -0,0 +1,145 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::FirstResponseTimeDistributionBuilder do + let!(:account) { create(:account) } + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + let!(:email_inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) } + let(:params) do + { + since: 1.week.ago.beginning_of_day.to_i.to_s, + until: Time.current.end_of_day.to_i.to_s + } + end + let(:builder) { described_class.new(account: account, params: params) } + + describe '#build' do + subject(:report) { builder.build } + + context 'when there are first response events across channels and time buckets' do + before do + # Web Widget: 0-1h bucket (30 minutes = 1800 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + # Web Widget: 1-4h bucket (2 hours = 7200 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 7_200, created_at: 2.days.ago) + # Web Widget: 4-8h bucket (6 hours = 21600 seconds) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 21_600, created_at: 3.days.ago) + # Email: 8-24h bucket (12 hours = 43200 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 43_200, created_at: 2.days.ago) + # Email: 24h+ bucket (48 hours = 172800 seconds) + create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response', + value: 172_800, created_at: 1.day.ago) + end + + it 'returns correct distribution for web widget channel' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 1, + '8-24h' => 0, + '24h+' => 0 + }) + end + + it 'returns correct distribution for email channel' do + expect(report['Channel::Email']).to eq({ + '0-1h' => 0, + '1-4h' => 0, + '4-8h' => 0, + '8-24h' => 1, + '24h+' => 1 + }) + end + end + + context 'when filtering by date range' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.weeks.ago) + end + + it 'only counts events within the date range' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when there are no first response events' do + it 'returns an empty hash' do + expect(report).to eq({}) + end + end + + context 'when events belong to another account' do + let(:other_account) { create(:account) } + let(:other_inbox) { create(:inbox, account: other_account) } + + before do + create(:reporting_event, account: other_account, inbox: other_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'does not include events from other accounts' do + expect(report).to eq({}) + end + end + + context 'when events have different names' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'conversation_resolved', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'reply_time', + value: 1_800, created_at: 2.days.ago) + end + + it 'only counts first_response events' do + expect(report['Channel::WebWidget']['0-1h']).to eq(1) + end + end + + context 'when no date range params are provided' do + let(:params) { {} } + + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.months.ago) + end + + it 'returns all events without date filtering' do + expect(report['Channel::WebWidget']['0-1h']).to eq(2) + end + end + + context 'with boundary values for time buckets' do + before do + # Exactly at 1 hour boundary (should be in 1-4h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_600, created_at: 2.days.ago) + # Just under 1 hour (should be in 0-1h bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 3_599, created_at: 2.days.ago) + # Exactly at 24 hour boundary (should be in 24h+ bucket) + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 86_400, created_at: 2.days.ago) + end + + it 'correctly assigns boundary values to buckets' do + expect(report['Channel::WebWidget']).to eq({ + '0-1h' => 1, + '1-4h' => 1, + '4-8h' => 0, + '8-24h' => 0, + '24h+' => 1 + }) + end + end + end +end diff --git a/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb b/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb new file mode 100644 index 000000000..524f89e4a --- /dev/null +++ b/spec/builders/v2/reports/inbox_label_matrix_builder_spec.rb @@ -0,0 +1,135 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::InboxLabelMatrixBuilder do + let!(:account) { create(:account) } + let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') } + let!(:inbox_two) { create(:inbox, account: account, name: 'Web Chat') } + let!(:label_one) { create(:label, account: account, title: 'bug') } + let!(:label_two) { create(:label, account: account, title: 'feature') } + let(:params) do + { + since: 1.week.ago.beginning_of_day.to_i.to_s, + until: Time.current.end_of_day.to_i.to_s + } + end + let(:builder) { described_class.new(account: account, params: params) } + + describe '#build' do + subject(:report) { builder.build } + + context 'when there are conversations with labels across inboxes' do + before do + c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + c1.update(label_list: [label_one.title]) + + c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 3.days.ago) + c2.update(label_list: [label_one.title, label_two.title]) + + c3 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago) + c3.update(label_list: [label_two.title]) + end + + it 'returns inboxes ordered by name' do + expect(report[:inboxes]).to eq([ + { id: inbox_one.id, name: 'Email Support' }, + { id: inbox_two.id, name: 'Web Chat' } + ]) + end + + it 'returns labels ordered by title' do + expect(report[:labels]).to eq([ + { id: label_one.id, title: 'bug' }, + { id: label_two.id, title: 'feature' } + ]) + end + + it 'returns correct conversation counts in the matrix' do + # Email Support: bug=2, feature=1 + # Web Chat: bug=0, feature=1 + expect(report[:matrix]).to eq([[2, 1], [0, 1]]) + end + end + + context 'when filtering by inbox_ids' do + let(:params) do + { + since: 1.week.ago.beginning_of_day.to_i.to_s, + until: Time.current.end_of_day.to_i.to_s, + inbox_ids: [inbox_one.id] + } + end + + before do + c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + c1.update(label_list: [label_one.title]) + + c2 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago) + c2.update(label_list: [label_one.title]) + end + + it 'only includes the specified inboxes and their counts' do + expect(report[:inboxes]).to eq([{ id: inbox_one.id, name: 'Email Support' }]) + expect(report[:matrix]).to eq([[1, 0]]) + end + end + + context 'when filtering by label_ids' do + let(:params) do + { + since: 1.week.ago.beginning_of_day.to_i.to_s, + until: Time.current.end_of_day.to_i.to_s, + label_ids: [label_one.id] + } + end + + before do + c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + c1.update(label_list: [label_one.title, label_two.title]) + end + + it 'only includes the specified labels and their counts' do + expect(report[:labels]).to eq([{ id: label_one.id, title: 'bug' }]) + expect(report[:matrix]).to eq([[1], [0]]) + end + end + + context 'when conversations are outside the date range' do + before do + c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + c1.update(label_list: [label_one.title]) + + c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.weeks.ago) + c2.update(label_list: [label_one.title]) + end + + it 'only counts conversations within the date range' do + expect(report[:matrix]).to eq([[1, 0], [0, 0]]) + end + end + + context 'when there are no conversations with labels' do + before do + create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + end + + it 'returns a matrix of zeros' do + expect(report[:matrix]).to eq([[0, 0], [0, 0]]) + end + end + + context 'when conversations belong to another account' do + let(:other_account) { create(:account) } + let(:other_inbox) { create(:inbox, account: other_account) } + + before do + c1 = create(:conversation, account: other_account, inbox: other_inbox, created_at: 2.days.ago) + other_label = create(:label, account: other_account, title: 'bug') + c1.update(label_list: [other_label.title]) + end + + it 'does not include conversations from other accounts' do + expect(report[:matrix]).to eq([[0, 0], [0, 0]]) + end + end + end +end diff --git a/spec/controllers/api/v2/accounts/reports_controller_spec.rb b/spec/controllers/api/v2/accounts/reports_controller_spec.rb index 2d505822f..c92425c32 100644 --- a/spec/controllers/api/v2/accounts/reports_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/reports_controller_spec.rb @@ -196,4 +196,103 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do end end end + + describe 'GET /api/v2/accounts/{account.id}/reports/inbox_label_matrix' do + let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') } + let!(:label_one) { create(:label, account: account, title: 'bug') } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix", + headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + before do + c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago) + c1.update(label_list: [label_one.title]) + end + + it 'returns the inbox label matrix' do + get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + + body = response.parsed_body + expect(body['inboxes']).to be_an(Array) + expect(body['labels']).to be_an(Array) + expect(body['matrix']).to be_an(Array) + end + + it 'filters by inbox_ids and label_ids' do + get "/api/v2/accounts/#{account.id}/reports/inbox_label_matrix", + params: { inbox_ids: [inbox_one.id], label_ids: [label_one.id] }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + + body = response.parsed_body + expect(body['inboxes'].length).to eq(1) + expect(body['labels'].length).to eq(1) + end + end + end + + describe 'GET /api/v2/accounts/{account.id}/reports/first_response_time_distribution' do + let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as agent' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated as admin' do + before do + create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response', + value: 1_800, created_at: 2.days.ago) + end + + it 'returns the first response time distribution' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + + body = response.parsed_body + expect(body).to be_a(Hash) + expect(body['Channel::WebWidget']).to include('0-1h', '1-4h', '4-8h', '8-24h', '24h+') + end + + it 'returns correct counts in buckets' do + get "/api/v2/accounts/#{account.id}/reports/first_response_time_distribution", + params: { since: 1.week.ago.to_i.to_s, until: Time.current.to_i.to_s }, + headers: admin.create_new_auth_token, as: :json + + body = response.parsed_body + expect(body['Channel::WebWidget']['0-1h']).to eq(1) + end + end + end end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index 1ea666b5d..b3c330252 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -161,11 +161,6 @@ RSpec.describe Captain::BaseTaskService do end end - it 'calls execute_ruby_llm_request with correct parameters' do - expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original - service.send(:make_api_call, model: model, messages: messages) - end - it 'instruments the LLM call' do expect(service).to receive(:instrument_llm_call).and_call_original service.send(:make_api_call, model: model, messages: messages) diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb index 81c1f3854..a53825ee4 100644 --- a/spec/lib/captain/reply_suggestion_service_spec.rb +++ b/spec/lib/captain/reply_suggestion_service_spec.rb @@ -19,6 +19,8 @@ RSpec.describe Captain::ReplySuggestionService do mock_context = instance_double(RubyLLM::Context, chat: mock_chat) allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_chat).to receive(:with_tool).and_return(mock_chat) + allow(mock_chat).to receive(:on_end_message).and_return(mock_chat) allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } } allow(mock_chat).to receive(:add_message) { |args| captured_messages << args } allow(mock_chat).to receive(:ask) do |msg| diff --git a/spec/models/concerns/account_email_rate_limitable_spec.rb b/spec/models/concerns/account_email_rate_limitable_spec.rb new file mode 100644 index 000000000..919c5f621 --- /dev/null +++ b/spec/models/concerns/account_email_rate_limitable_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe AccountEmailRateLimitable do + let(:account) { create(:account) } + + describe '#email_rate_limit' do + it 'returns account-level override when set' do + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + + it 'returns global config when no account override' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + expect(account.email_rate_limit).to eq(200) + end + + it 'returns account override over global config' do + InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200) + account.update!(limits: { 'emails' => 50 }) + expect(account.email_rate_limit).to eq(50) + end + end + + describe '#within_email_rate_limit?' do + before do + account.update!(limits: { 'emails' => 2 }) + end + + it 'returns true when under limit' do + expect(account).to be_within_email_rate_limit + end + + it 'returns false when at limit' do + 2.times { account.increment_email_sent_count } + expect(account).not_to be_within_email_rate_limit + end + end + + describe '#increment_email_sent_count' do + it 'increments the counter' do + expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1) + end + + it 'sets TTL on first increment' do + key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s) + allow(Redis::Alfred).to receive(:incr).and_return(1) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL) + end + + it 'does not reset TTL on subsequent increments' do + allow(Redis::Alfred).to receive(:incr).and_return(2) + allow(Redis::Alfred).to receive(:expire) + + account.increment_email_sent_count + + expect(Redis::Alfred).not_to have_received(:expire) + end + end +end diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb index 7c0970fe1..0c1563c79 100644 --- a/spec/services/messages/send_email_notification_service_spec.rb +++ b/spec/services/messages/send_email_notification_service_spec.rb @@ -99,6 +99,20 @@ describe Messages::SendEmailNotificationService do end end + context 'when account email rate limit is exceeded' do + let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + + before do + conversation.contact.update!(email: 'test@example.com') + allow_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance + end + + it 'does not enqueue job' do + expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob) + end + end + context 'when channel does not support email notifications' do let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) } let(:conversation) { create(:conversation, account: account, inbox: inbox) } diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 308d61f62..74fb2f6e2 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -161,10 +161,23 @@ describe Whatsapp::FacebookApiClient do context 'when successful' do before do + # Step 1: Subscribe app to WABA (no body) + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2: Override callback URL (with body) stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json ) .to_return( status: 200, @@ -179,18 +192,45 @@ describe Whatsapp::FacebookApiClient do end end - context 'when failed' do + context 'when app subscription fails' do before do stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") .with( - headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, - body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } ) - .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json) + .to_return(status: 400, body: { error: 'App subscription to WABA failed' }.to_json) end it 'raises an error' do - expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/) + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/App subscription to WABA failed/) + end + end + + context 'when callback override fails' do + before do + # Step 1 succeeds + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' } + ) + .to_return( + status: 200, + body: { success: true }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + # Step 2 fails + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps") + .with( + headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }, + body: { override_callback_uri: callback_url, verify_token: verify_token, + subscribed_fields: %w[messages smb_message_echoes] }.to_json + ) + .to_return(status: 400, body: { error: 'Webhook callback override failed' }.to_json) + end + + it 'raises an error' do + expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook callback override failed/) end end end diff --git a/swagger/definitions/index.yml b/swagger/definitions/index.yml index fd9cc1664..1e64bf97b 100644 --- a/swagger/definitions/index.yml +++ b/swagger/definitions/index.yml @@ -225,6 +225,16 @@ agent_conversation_metrics: $ref: './resource/reports/conversation/agent.yml' channel_summary: $ref: './resource/reports/channel_summary.yml' +first_response_time_distribution: + $ref: './resource/reports/first_response_time_distribution.yml' +inbox_label_matrix: + $ref: './resource/reports/inbox_label_matrix.yml' +inbox_summary: + $ref: './resource/reports/inbox_summary.yml' +agent_summary: + $ref: './resource/reports/agent_summary.yml' +team_summary: + $ref: './resource/reports/team_summary.yml' contact_detail: $ref: ./resource/contact_detail.yml diff --git a/swagger/definitions/resource/reports/agent_summary.yml b/swagger/definitions/resource/reports/agent_summary.yml new file mode 100644 index 000000000..47c632ddf --- /dev/null +++ b/swagger/definitions/resource/reports/agent_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Agent summary report containing conversation statistics grouped by agent. +items: + type: object + properties: + id: + type: number + description: The agent (user) ID + conversations_count: + type: number + description: Number of conversations assigned to the agent during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved by the agent during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 150 + resolved_conversations_count: 120 + avg_resolution_time: 3600 + avg_first_response_time: 300 + avg_reply_time: 600 + - id: 2 + conversations_count: 75 + resolved_conversations_count: 60 + avg_resolution_time: 1800 + avg_first_response_time: 180 + avg_reply_time: 420 diff --git a/swagger/definitions/resource/reports/first_response_time_distribution.yml b/swagger/definitions/resource/reports/first_response_time_distribution.yml new file mode 100644 index 000000000..790e5afe6 --- /dev/null +++ b/swagger/definitions/resource/reports/first_response_time_distribution.yml @@ -0,0 +1,34 @@ +type: object +description: First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets. +additionalProperties: + type: object + description: First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api) + properties: + 0-1h: + type: number + description: Number of conversations with first response time less than 1 hour + 1-4h: + type: number + description: Number of conversations with first response time between 1-4 hours + 4-8h: + type: number + description: Number of conversations with first response time between 4-8 hours + 8-24h: + type: number + description: Number of conversations with first response time between 8-24 hours + 24h+: + type: number + description: Number of conversations with first response time greater than 24 hours +example: + Channel::WebWidget: + 0-1h: 150 + 1-4h: 80 + 4-8h: 45 + 8-24h: 30 + 24h+: 15 + Channel::Api: + 0-1h: 75 + 1-4h: 40 + 4-8h: 20 + 8-24h: 10 + 24h+: 5 diff --git a/swagger/definitions/resource/reports/inbox_label_matrix.yml b/swagger/definitions/resource/reports/inbox_label_matrix.yml new file mode 100644 index 000000000..a9b4ebc59 --- /dev/null +++ b/swagger/definitions/resource/reports/inbox_label_matrix.yml @@ -0,0 +1,50 @@ +type: object +description: Inbox-label matrix report showing the count of conversations for each inbox-label combination. +properties: + inboxes: + type: array + description: List of inboxes included in the report + items: + type: object + properties: + id: + type: number + description: The inbox ID + name: + type: string + description: The inbox name + labels: + type: array + description: List of labels included in the report + items: + type: object + properties: + id: + type: number + description: The label ID + title: + type: string + description: The label title + matrix: + type: array + description: 2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j] + items: + type: array + items: + type: number +example: + inboxes: + - id: 1 + name: Website Chat + - id: 2 + name: Email Support + labels: + - id: 1 + title: bug + - id: 2 + title: feature-request + - id: 3 + title: urgent + matrix: + - [10, 5, 3] + - [8, 12, 2] diff --git a/swagger/definitions/resource/reports/inbox_summary.yml b/swagger/definitions/resource/reports/inbox_summary.yml new file mode 100644 index 000000000..9a9adcf6b --- /dev/null +++ b/swagger/definitions/resource/reports/inbox_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Inbox summary report containing conversation statistics grouped by inbox. +items: + type: object + properties: + id: + type: number + description: The inbox ID + conversations_count: + type: number + description: Number of conversations created in the inbox during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved in the inbox during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 150 + resolved_conversations_count: 120 + avg_resolution_time: 3600 + avg_first_response_time: 300 + avg_reply_time: 600 + - id: 2 + conversations_count: 75 + resolved_conversations_count: 60 + avg_resolution_time: 1800 + avg_first_response_time: 180 + avg_reply_time: 420 diff --git a/swagger/definitions/resource/reports/team_summary.yml b/swagger/definitions/resource/reports/team_summary.yml new file mode 100644 index 000000000..98f5895a9 --- /dev/null +++ b/swagger/definitions/resource/reports/team_summary.yml @@ -0,0 +1,39 @@ +type: array +description: Team summary report containing conversation statistics grouped by team. +items: + type: object + properties: + id: + type: number + description: The team ID + conversations_count: + type: number + description: Number of conversations assigned to the team during the date range + resolved_conversations_count: + type: number + description: Number of conversations resolved by the team during the date range + avg_resolution_time: + type: number + nullable: true + description: Average time (in seconds) to resolve conversations. Null if no data available. + avg_first_response_time: + type: number + nullable: true + description: Average time (in seconds) for the first response. Null if no data available. + avg_reply_time: + type: number + nullable: true + description: Average time (in seconds) between replies. Null if no data available. +example: + - id: 1 + conversations_count: 250 + resolved_conversations_count: 200 + avg_resolution_time: 2800 + avg_first_response_time: 240 + avg_reply_time: 500 + - id: 2 + conversations_count: 180 + resolved_conversations_count: 150 + avg_resolution_time: 2400 + avg_first_response_time: 200 + avg_reply_time: 450 diff --git a/swagger/index.html b/swagger/index.html index eb09d7768..e1546e56f 100644 --- a/swagger/index.html +++ b/swagger/index.html @@ -18,6 +18,6 @@ - + diff --git a/swagger/paths/application/reports/agent_summary.yml b/swagger/paths/application/reports/agent_summary.yml new file mode 100644 index 000000000..ac899734f --- /dev/null +++ b/swagger/paths/application/reports/agent_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-agent-summary-report +summary: Get conversation statistics grouped by agent +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by agent for a given date range. + Returns metrics for each agent including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/agent_summary' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/first_response_time_distribution.yml b/swagger/paths/application/reports/first_response_time_distribution.yml new file mode 100644 index 000000000..a5e092301 --- /dev/null +++ b/swagger/paths/application/reports/first_response_time_distribution.yml @@ -0,0 +1,24 @@ +tags: + - Reports +operationId: get-first-response-time-distribution +summary: Get first response time distribution by channel +security: + - userApiKey: [] +description: | + Get the distribution of first response times grouped by channel type. + Returns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/first_response_time_distribution' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/inbox_label_matrix.yml b/swagger/paths/application/reports/inbox_label_matrix.yml new file mode 100644 index 000000000..a99dea99d --- /dev/null +++ b/swagger/paths/application/reports/inbox_label_matrix.yml @@ -0,0 +1,25 @@ +tags: + - Reports +operationId: get-inbox-label-matrix +summary: Get inbox-label matrix report +security: + - userApiKey: [] +description: | + Get a matrix showing the count of conversations for each inbox-label combination. + Returns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations + in a specific inbox that have a specific label applied. + + **Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/inbox_label_matrix' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/inbox_summary.yml b/swagger/paths/application/reports/inbox_summary.yml new file mode 100644 index 000000000..2c687f969 --- /dev/null +++ b/swagger/paths/application/reports/inbox_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-inbox-summary-report +summary: Get conversation statistics grouped by inbox +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by inbox for a given date range. + Returns metrics for each inbox including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/inbox_summary' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/application/reports/team_summary.yml b/swagger/paths/application/reports/team_summary.yml new file mode 100644 index 000000000..a6343eb11 --- /dev/null +++ b/swagger/paths/application/reports/team_summary.yml @@ -0,0 +1,23 @@ +tags: + - Reports +operationId: get-team-summary-report +summary: Get conversation statistics grouped by team +security: + - userApiKey: [] +description: | + Get conversation statistics grouped by team for a given date range. + Returns metrics for each team including conversation counts, resolution counts, + average first response time, average resolution time, and average reply time. +responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/team_summary' + '403': + description: Access denied + content: + application/json: + schema: + $ref: '#/components/schemas/bad_request_error' diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 2e7c5514e..24e460b4c 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -641,6 +641,23 @@ # Channel summary report (Available in 4.10.0+) /api/v2/accounts/{account_id}/summary_reports/channel: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + get: + $ref: './application/reports/channel_summary.yml' + +# Inbox summary report +/api/v2/accounts/{account_id}/summary_reports/inbox: parameters: - $ref: '#/components/parameters/account_id' - in: query @@ -657,9 +674,101 @@ name: business_hours schema: type: boolean - description: Whether to filter by business hours. + description: Whether to calculate metrics using business hours only. get: - $ref: './application/reports/channel_summary.yml' + $ref: './application/reports/inbox_summary.yml' + +# Agent summary report +/api/v2/accounts/{account_id}/summary_reports/agent: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + - in: query + name: business_hours + schema: + type: boolean + description: Whether to calculate metrics using business hours only. + get: + $ref: './application/reports/agent_summary.yml' + +# Team summary report +/api/v2/accounts/{account_id}/summary_reports/team: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + - in: query + name: business_hours + schema: + type: boolean + description: Whether to calculate metrics using business hours only. + get: + $ref: './application/reports/team_summary.yml' + +# First response time distribution report +/api/v2/accounts/{account_id}/reports/first_response_time_distribution: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + get: + $ref: './application/reports/first_response_time_distribution.yml' + +# Inbox-label matrix report +/api/v2/accounts/{account_id}/reports/inbox_label_matrix: + parameters: + - $ref: '#/components/parameters/account_id' + - in: query + name: since + schema: + type: string + description: The timestamp from where report should start (Unix timestamp). + - in: query + name: until + schema: + type: string + description: The timestamp from where report should stop (Unix timestamp). + - in: query + name: inbox_ids + schema: + type: array + items: + type: integer + description: Filter by specific inbox IDs. + - in: query + name: label_ids + schema: + type: array + items: + type: integer + description: Filter by specific label IDs. + get: + $ref: './application/reports/inbox_label_matrix.yml' # Conversations Messages /accounts/{account_id}/conversations/{conversation_id}/messages: diff --git a/swagger/swagger.json b/swagger/swagger.json index ee33fe56f..934864911 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -7890,14 +7890,6 @@ "type": "string" }, "description": "The timestamp from where report should stop (Unix timestamp)." - }, - { - "in": "query", - "name": "business_hours", - "schema": { - "type": "boolean" - }, - "description": "Whether to filter by business hours." } ], "get": { @@ -7946,6 +7938,342 @@ } } }, + "/api/v2/accounts/{account_id}/summary_reports/inbox": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-summary-report", + "summary": "Get conversation statistics grouped by inbox", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/agent": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-agent-summary-report", + "summary": "Get conversation statistics grouped by agent", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/team": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-team-summary-report", + "summary": "Get conversation statistics grouped by team", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/team_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-first-response-time-distribution", + "summary": "Get first response time distribution by channel", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_response_time_distribution" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/inbox_label_matrix": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "inbox_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific inbox IDs." + }, + { + "in": "query", + "name": "label_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific label IDs." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-label-matrix", + "summary": "Get inbox-label matrix report", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_label_matrix" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, "/accounts/{account_id}/conversations/{conversation_id}/messages": { "parameters": [ { @@ -11781,6 +12109,302 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index ef5ec5389..77a95da33 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -6433,14 +6433,6 @@ "type": "string" }, "description": "The timestamp from where report should stop (Unix timestamp)." - }, - { - "in": "query", - "name": "business_hours", - "schema": { - "type": "boolean" - }, - "description": "Whether to filter by business hours." } ], "get": { @@ -6488,6 +6480,342 @@ } } } + }, + "/api/v2/accounts/{account_id}/summary_reports/inbox": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-summary-report", + "summary": "Get conversation statistics grouped by inbox", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by inbox for a given date range.\nReturns metrics for each inbox including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/agent": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-agent-summary-report", + "summary": "Get conversation statistics grouped by agent", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by agent for a given date range.\nReturns metrics for each agent including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/agent_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/summary_reports/team": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "business_hours", + "schema": { + "type": "boolean" + }, + "description": "Whether to calculate metrics using business hours only." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-team-summary-report", + "summary": "Get conversation statistics grouped by team", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get conversation statistics grouped by team for a given date range.\nReturns metrics for each team including conversation counts, resolution counts,\naverage first response time, average resolution time, and average reply time.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/team_summary" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/first_response_time_distribution": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-first-response-time-distribution", + "summary": "Get first response time distribution by channel", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get the distribution of first response times grouped by channel type.\nReturns conversation counts in different time buckets (0-1h, 1-4h, 4-8h, 8-24h, 24h+) for each channel type.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/first_response_time_distribution" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } + }, + "/api/v2/accounts/{account_id}/reports/inbox_label_matrix": { + "parameters": [ + { + "$ref": "#/components/parameters/account_id" + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should start (Unix timestamp)." + }, + { + "in": "query", + "name": "until", + "schema": { + "type": "string" + }, + "description": "The timestamp from where report should stop (Unix timestamp)." + }, + { + "in": "query", + "name": "inbox_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific inbox IDs." + }, + { + "in": "query", + "name": "label_ids", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + }, + "description": "Filter by specific label IDs." + } + ], + "get": { + "tags": [ + "Reports" + ], + "operationId": "get-inbox-label-matrix", + "summary": "Get inbox-label matrix report", + "security": [ + { + "userApiKey": [] + } + ], + "description": "Get a matrix showing the count of conversations for each inbox-label combination.\nReturns a list of inboxes, labels, and a 2D matrix where each cell contains the count of conversations\nin a specific inbox that have a specific label applied.\n\n**Note:** This API endpoint is available only in Chatwoot version 4.11.0 and above.\n", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/inbox_label_matrix" + } + } + } + }, + "403": { + "description": "Access denied", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/bad_request_error" + } + } + } + } + } + } } }, "components": { @@ -10288,6 +10616,302 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/client_swagger.json b/swagger/tag_groups/client_swagger.json index bcf4bb178..a786aebea 100644 --- a/swagger/tag_groups/client_swagger.json +++ b/swagger/tag_groups/client_swagger.json @@ -4424,6 +4424,302 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/other_swagger.json b/swagger/tag_groups/other_swagger.json index 01d1adc46..12dd566c4 100644 --- a/swagger/tag_groups/other_swagger.json +++ b/swagger/tag_groups/other_swagger.json @@ -3839,6 +3839,302 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": { diff --git a/swagger/tag_groups/platform_swagger.json b/swagger/tag_groups/platform_swagger.json index 2b81a67fd..fd74b12e5 100644 --- a/swagger/tag_groups/platform_swagger.json +++ b/swagger/tag_groups/platform_swagger.json @@ -4600,6 +4600,302 @@ } } }, + "first_response_time_distribution": { + "type": "object", + "description": "First response time distribution report grouped by channel type. Shows the count of conversations with first response times in different time buckets.", + "additionalProperties": { + "type": "object", + "description": "First response time distribution for a specific channel type (e.g., Channel::WebWidget, Channel::Api)", + "properties": { + "0-1h": { + "type": "number", + "description": "Number of conversations with first response time less than 1 hour" + }, + "1-4h": { + "type": "number", + "description": "Number of conversations with first response time between 1-4 hours" + }, + "4-8h": { + "type": "number", + "description": "Number of conversations with first response time between 4-8 hours" + }, + "8-24h": { + "type": "number", + "description": "Number of conversations with first response time between 8-24 hours" + }, + "24h+": { + "type": "number", + "description": "Number of conversations with first response time greater than 24 hours" + } + } + }, + "example": { + "Channel::WebWidget": { + "0-1h": 150, + "1-4h": 80, + "4-8h": 45, + "8-24h": 30, + "24h+": 15 + }, + "Channel::Api": { + "0-1h": 75, + "1-4h": 40, + "4-8h": 20, + "8-24h": 10, + "24h+": 5 + } + } + }, + "inbox_label_matrix": { + "type": "object", + "description": "Inbox-label matrix report showing the count of conversations for each inbox-label combination.", + "properties": { + "inboxes": { + "type": "array", + "description": "List of inboxes included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "name": { + "type": "string", + "description": "The inbox name" + } + } + } + }, + "labels": { + "type": "array", + "description": "List of labels included in the report", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The label ID" + }, + "title": { + "type": "string", + "description": "The label title" + } + } + } + }, + "matrix": { + "type": "array", + "description": "2D array where matrix[i][j] represents the count of conversations in inboxes[i] with labels[j]", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + }, + "example": { + "inboxes": [ + { + "id": 1, + "name": "Website Chat" + }, + { + "id": 2, + "name": "Email Support" + } + ], + "labels": [ + { + "id": 1, + "title": "bug" + }, + { + "id": 2, + "title": "feature-request" + }, + { + "id": 3, + "title": "urgent" + } + ], + "matrix": [ + [ + 10, + 5, + 3 + ], + [ + 8, + 12, + 2 + ] + ] + } + }, + "inbox_summary": { + "type": "array", + "description": "Inbox summary report containing conversation statistics grouped by inbox.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The inbox ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations created in the inbox during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved in the inbox during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "agent_summary": { + "type": "array", + "description": "Agent summary report containing conversation statistics grouped by agent.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The agent (user) ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the agent during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the agent during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 150, + "resolved_conversations_count": 120, + "avg_resolution_time": 3600, + "avg_first_response_time": 300, + "avg_reply_time": 600 + }, + { + "id": 2, + "conversations_count": 75, + "resolved_conversations_count": 60, + "avg_resolution_time": 1800, + "avg_first_response_time": 180, + "avg_reply_time": 420 + } + ] + }, + "team_summary": { + "type": "array", + "description": "Team summary report containing conversation statistics grouped by team.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The team ID" + }, + "conversations_count": { + "type": "number", + "description": "Number of conversations assigned to the team during the date range" + }, + "resolved_conversations_count": { + "type": "number", + "description": "Number of conversations resolved by the team during the date range" + }, + "avg_resolution_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) to resolve conversations. Null if no data available." + }, + "avg_first_response_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) for the first response. Null if no data available." + }, + "avg_reply_time": { + "type": "number", + "nullable": true, + "description": "Average time (in seconds) between replies. Null if no data available." + } + } + }, + "example": [ + { + "id": 1, + "conversations_count": 250, + "resolved_conversations_count": 200, + "avg_resolution_time": 2800, + "avg_first_response_time": 240, + "avg_reply_time": 500 + }, + { + "id": 2, + "conversations_count": 180, + "resolved_conversations_count": 150, + "avg_resolution_time": 2400, + "avg_first_response_time": 200, + "avg_reply_time": 450 + } + ] + }, "contact_detail": { "type": "object", "properties": {