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