Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 98ffdabc99 Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-02-02 14:29:53 +04:00
Muhsin KelothandGitHub b686d14044 feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
PranavandGitHub e9e6de5690 fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410)
The specs break only in Circle CI, we have to figure out the root cause
for the same. At the moment, I have increased the parallelism to fix
this.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- Add API documentation for inbox, agent, and team summary report
endpoints
- These endpoints return conversation statistics grouped by
inbox/agent/team for a given date range

Endpoints documented:
GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation
stats grouped by inbox │
GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation
stats grouped by agent │
GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation
stats grouped by team │

Query parameters (all endpoints):
- since - Start timestamp (Unix)
- until - End timestamp (Unix)
- business_hours - Calculate metrics using business hours only

Response fields:
- id - Inbox/Agent/Team ID
- conversations_count - Total conversations in date range
- resolved_conversations_count - Resolved conversations in date range
- avg_resolution_time - Average resolution time (seconds)
- avg_first_response_time - Average first response time (seconds)
- avg_reply_time - Average reply time (seconds)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c chore: Update report documentation (#13408)
New API Documentation

GET
/api/v2/accounts/{account_id}/reports/first_response_time_distribution
  - Returns first response time distribution grouped by channel type
- Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h,
24h+
  - Parameters: since, until (Unix timestamps)

  GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix
  - Returns a matrix of conversation counts for inbox-label combinations
  - Parameters: since, until, inbox_ids[], label_ids[]

  Fixes

  - Removed unused business_hours boolean parameter from
  /api/v2/accounts/{account_id}/summary_reports/channel
- Updated ReDoc script from unstable @next to stable @2.1.5 version to
fix empty swagger page
2026-01-30 22:33:03 +04:00
5ec77aca64 feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.

Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).

- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance

Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
    "Channel::WebWidget": {
      "0-1h": 120,
      "1-4h": 85,
      "4-8h": 32,
      "8-24h": 12,
      "24h+": 3
    },
    "Channel::Email": {
      "0-1h": 12,
      "1-4h": 28,
      "4-8h": 45,
      "8-24h": 35,
      "24h+": 10
    },
    "Channel::FacebookPage": {
      "0-1h": 50,
      "1-4h": 30,
      "4-8h": 15,
      "8-24h": 8,
      "24h+": 2
    }
  }
```

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
Sivin VargheseandGitHub 85324c82fa fix: Formatting issue with reply preview content (#13399) 2026-01-30 16:35:32 +05:30
81307d5aea feat: search documentation tool for reply suggestions (#13340)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-30 16:18:33 +05:30
6f45af605c feat: Add inbox-label matrix report endpoint (#13394)
This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-29 13:32:59 -08:00
a32565d72b fix: velma connection limit (#13395)
## Description 
Make the $velma Redis connection pool size configurable via
`REDIS_VELMA_SIZE` environment variable (default: 5, matching current
behavior)
The $velma pool is used exclusively by Rack::Attack for rate limiting
and was the only Redis pool with a hardcoded size

## Fixes

Under high traffic, the hardcoded $velma pool (size: 5) causes
connection contention. Every HTTP request passes through Rack::Attack
middleware, which requires a $velma Redis connection. When
`WEB_CONCURRENCY=2` and `RAILS_MAX_THREADS=10` (20 concurrent threads),
the 4:1 thread-to-connection ratio causes threads to queue for up to 1
second (the pool timeout), resulting in intermittent request latency
spikes during traffic bursts.

The $alfred pool was already configurable via REDIS_ALFRED_SIZE — this
change brings $velma to parity.


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes only Redis connection pool sizing for Rack::Attack;
misconfiguration could cause rate-limiting Redis contention or extra
connections but no data/auth logic changes.
> 
> **Overview**
> Makes the `velma` Redis connection pool (used by Rack::Attack)
configurable via a new `REDIS_VELMA_SIZE` env var, replacing the
previously hardcoded pool size.
> 
> Documents `REDIS_VELMA_SIZE` in `.env.example` alongside the existing
`REDIS_ALFRED_SIZE` setting.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dcbc946f2e1d7356dc743178ca46cdf12cb25c78. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-29 20:53:41 +05:30
Muhsin KelothandGitHub 585e15626c Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-27 11:10:46 +04:00
Muhsin KelothandGitHub 66463f81b6 Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-19 13:10:09 +04:00
Muhsin KelothandGitHub e7b752b74f Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-15 13:28:27 +04:00
Muhsin KelothandGitHub dba1be7c84 Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-12 14:01:52 +04:00
Muhsin KelothandGitHub 82f24dbfab Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-12 11:35:50 +04:00
Muhsin KelothandGitHub 0084097ddf Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-06 10:39:35 +04:00
Muhsin KelothandGitHub 967c379226 Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2026-01-05 19:27:30 +04:00
Muhsin KelothandGitHub 2c98672547 Merge branch 'develop' into codex/linear-mention-cw-5548-cannot-create-a-linear-ticket 2025-12-17 19:04:08 +05:30
Muhsin Keloth 62f54501bb Fix Linear mutation string escaping 2025-12-16 00:10:21 +05:30
60 changed files with 3449 additions and 245 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 20
steps:
- checkout
- node/install:
+1
View File
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
@@ -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
@@ -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
@@ -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
@@ -1,81 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
scope = account.messages.outgoing.unscope(:order).where(created_at: range)
scope = scope.where.not(sender_type: ['AgentBot', 'Captain::Assistant']) if params[:group_by].to_s == 'agent'
scope
end
def build_by_agent
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { assignee_id: nil })
.group('conversations.assignee_id')
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -62,8 +62,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def outgoing_messages_count
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
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
@@ -145,9 +156,17 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def outgoing_messages_count_params
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
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
@@ -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({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex mb-2 w-full message-bubble-container"
class="flex w-full mb-2 message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
const { content, attachments } = inReplyTo.value;
if (content) return content;
if (content) return new MessageFormatter(content).formattedMessage;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
</div>
<slot />
<MessageMeta
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
@@ -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",
@@ -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": {
@@ -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',
+1 -1
View File
@@ -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.
+50
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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?
+4
View File
@@ -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
+3 -3
View File
@@ -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
+2 -1
View File
@@ -66,7 +66,8 @@ class Whatsapp::FacebookApiClient
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
)
@@ -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
@@ -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
+2 -1
View File
@@ -13,7 +13,8 @@ end
# Velma : Determined protector
# used in rack attack
$velma = ConnectionPool.new(size: 5, timeout: 1) do
velma_size = ENV.fetch('REDIS_VELMA_SIZE', 10)
$velma = ConnectionPool.new(size: velma_size, timeout: 1) do
config = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('velma', redis: config, warning: true)
end
+2 -1
View File
@@ -444,7 +444,8 @@ Rails.application.routes.draw do
get :conversations_summary
get :conversation_traffic
get :bot_metrics
get :outgoing_messages_count
get :inbox_label_matrix
get :first_response_time_distribution
end
end
resource :year_in_review, only: [:show]
@@ -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
+2 -1
View File
@@ -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_20_121402) 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"
@@ -1115,6 +1115,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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"
@@ -1,4 +1,6 @@
class Captain::Tools::BaseTool < RubyLLM::Tool
prepend Captain::Tools::Instrumentation
attr_accessor :assistant
def initialize(assistant, user: nil)
@@ -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
@@ -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
@@ -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
+24 -16
View File
@@ -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')
+2
View File
@@ -38,3 +38,5 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
'reply_suggestion'
end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
+48
View File
@@ -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
+4
View File
@@ -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
@@ -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.
+3 -3
View File
@@ -2,8 +2,8 @@ module Linear::Mutations
def self.graphql_value(value)
case value
when String
# Strings must be enclosed in double quotes
"\"#{value.gsub("\n", '\\n')}\""
# Strings must be enclosed in double quotes and escaped for GraphQL
value.to_json
when Array
# Arrays need to be recursively converted
"[#{value.map { |v| graphql_value(v) }.join(', ')}]"
@@ -47,7 +47,7 @@ module Linear::Mutations
<<~GRAPHQL
mutation {
attachmentLinkURL(url: "#{link}", issueId: "#{issue_id}", title: "#{title}"#{user_params_str}) {
attachmentLinkURL(url: #{graphql_value(link)}, issueId: #{graphql_value(issue_id)}, title: #{graphql_value(title)}#{user_params_str}) {
success
attachment {
id
@@ -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
@@ -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
@@ -197,121 +197,101 @@ RSpec.describe Api::V2::Accounts::ReportsController, type: :request do
end
end
describe 'GET /api/v2/accounts/{account.id}/reports/outgoing_messages_count' do
let(:since_epoch) { 1.week.ago.to_i.to_s }
let(:until_epoch) { 1.day.from_now.to_i.to_s }
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/outgoing_messages_count",
params: { group_by: 'agent', since: since_epoch, until: until_epoch }
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/outgoing_messages_count",
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
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
let(:agent2) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
let(:inbox2) { create(:inbox, account: account) }
# Separate conversations for agent and team grouping because
# model callbacks clear assignee_id when team is set.
before do
conv_agent = create(:conversation, account: account, inbox: inbox, assignee: agent)
conv_agent2 = create(:conversation, account: account, inbox: inbox2, assignee: agent2)
conv_team = create(:conversation, account: account, inbox: inbox, team: team)
create_list(:message, 3, account: account, conversation: conv_agent, inbox: inbox, message_type: :outgoing)
create_list(:message, 2, account: account, conversation: conv_agent2, inbox: inbox2, message_type: :outgoing)
create_list(:message, 4, account: account, conversation: conv_team, inbox: inbox, message_type: :outgoing)
# incoming message should not be counted
create(:message, account: account, conversation: conv_agent, inbox: inbox, message_type: :incoming)
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
end
it 'returns outgoing message counts grouped by agent' do
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
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)
data = response.parsed_body
expect(data).to be_an(Array)
agent_entry = data.find { |e| e['id'] == agent.id }
agent2_entry = data.find { |e| e['id'] == agent2.id }
expect(agent_entry['outgoing_messages_count']).to eq(3)
expect(agent2_entry['outgoing_messages_count']).to eq(2)
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 'returns outgoing message counts grouped by team' do
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
params: { group_by: 'team', since: since_epoch, until: until_epoch },
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)
data = response.parsed_body
expect(data).to be_an(Array)
expect(data.length).to eq(1)
expect(data.first['id']).to eq(team.id)
expect(data.first['outgoing_messages_count']).to eq(4)
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 outgoing message counts grouped by inbox' do
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
params: { group_by: 'inbox', since: since_epoch, until: until_epoch },
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)
data = response.parsed_body
expect(data).to be_an(Array)
inbox_entry = data.find { |e| e['id'] == inbox.id }
inbox2_entry = data.find { |e| e['id'] == inbox2.id }
expect(inbox_entry['outgoing_messages_count']).to eq(7)
expect(inbox2_entry['outgoing_messages_count']).to eq(2)
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 outgoing message counts grouped by label' do
label = create(:label, account: account, title: 'support')
conversation = account.conversations.first
conversation.label_list.add('support')
conversation.save!
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
params: { group_by: 'label', since: since_epoch, until: until_epoch },
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
expect(response).to have_http_status(:success)
data = response.parsed_body
expect(data).to be_an(Array)
expect(data.length).to eq(1)
expect(data.first['id']).to eq(label.id)
expect(data.first['name']).to eq('support')
end
it 'excludes bot messages when grouped by agent' do
bot = create(:agent_bot)
bot_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
create(:message, account: account, conversation: bot_conversation, inbox: inbox,
message_type: :outgoing, sender: bot)
get "/api/v2/accounts/#{account.id}/reports/outgoing_messages_count",
params: { group_by: 'agent', since: since_epoch, until: until_epoch },
headers: admin.create_new_auth_token, as: :json
data = response.parsed_body
agent_entry = data.find { |e| e['id'] == agent.id }
# 3 from before block; bot message excluded
expect(agent_entry['outgoing_messages_count']).to eq(3)
body = response.parsed_body
expect(body['Channel::WebWidget']['0-1h']).to eq(1)
end
end
end
@@ -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)
@@ -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|
@@ -164,7 +164,8 @@ describe Whatsapp::FacebookApiClient 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
body: { override_callback_uri: callback_url, verify_token: verify_token,
subscribed_fields: %w[messages smb_message_echoes] }.to_json
)
.to_return(
status: 200,
@@ -184,7 +185,8 @@ describe Whatsapp::FacebookApiClient 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
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 subscription failed' }.to_json)
end
+10
View File
@@ -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
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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
+1 -1
View File
@@ -18,6 +18,6 @@
</head>
<body>
<redoc spec-url='/swagger/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js"> </script>
</body>
</html>
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
+111 -2
View File
@@ -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:
+632 -8
View File
@@ -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": {
+632 -8
View File
@@ -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": {
+296
View File
@@ -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": {
+296
View File
@@ -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": {
+296
View File
@@ -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": {