Compare commits

...
Author SHA1 Message Date
Shivam Mishra fb8c9e4453 feat: add more agent bot events 2026-01-07 14:41:12 +05:30
Shivam MishraandGitHub 566de02385 feat: allow agent bot and captain responses to reset waiting since (#13181)
When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.

### Timeline from Production Data

```
Dec 12, 16:20:14 - Customer sends message (ID: 368451924)
                   ↓ waiting_since = Dec 12, 16:20:14

Dec 12, 16:20:17 - AgentBot replies (ID: 368451960)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (Bot response doesn't clear it)

14-day gap        - Customer satisfied, no messages
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 26, 22:25:45 - Customer sends new message (ID: 383522275)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (New message doesn't reset it)

Dec 26-27         - More AgentBot interactions
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517)
                   ↓ Reply time calculated: 1,268,404 seconds
                   ↓ = 14.7 DAYS ❌
```
## Root Cause

The core issues is in `app/models/message.rb`, where **AgentBot messages
does not clear `waiting_since`** - The `human_response?` method only
returns true for `User` senders, so bot replies never trigger the
clearing logic. This means once `waiting_since` is set, it stays set
even when customers send new messages after receiving bot responses.

The solution is to simply reset `waiting_since` **after a bot has
responded**. This ensures reply time metrics reflect actual human agent
response times, not bot-handled periods.

### What triggers the rest

This is an intentional "gotcha", that only `AgentBot` and
`Captain::Assistant` messages trigger the waiting time reset. Automation
and campaign messages maintain current behavior (no reset). This is
because interactive bot assistants provide conversational help that
might satisfy customers. Automation and campaigns are one-way
communications and shouldn't affect waiting time calculations.

## Related Work

Extends PR #11787 which fixed `waiting_since` clearing on conversation
resolution. This PR addresses the bot interaction scenario which was not
covered by that fix.

Scripts to clean data:
https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a
2026-01-07 13:57:43 +05:30
Shivam MishraandGitHub 02ab856520 feat(CW-6187): include headers from incoming emails (#13139) 2026-01-07 12:45:54 +05:30
Tanmay Deep SharmaandGitHub e58600d1b9 fix: the webhook url to be text (#13157)
## Description
Change the url type from string to text, to support more than 255
characters

Fixes # (issue)
https://app.chatwoot.com/app/accounts/1/conversations/65240

## Type of change

Please delete options that are not relevant.

- [ ] 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
2026-01-06 15:23:54 +05:30
12 changed files with 340 additions and 12 deletions
+26
View File
@@ -15,6 +15,32 @@ class AgentBotListener < BaseListener
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_created(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_status_changed(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_updated(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def message_created(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
+19 -5
View File
@@ -322,12 +322,21 @@ class Message < ApplicationRecord
end
def update_waiting_since
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -341,6 +350,11 @@ class Message < ApplicationRecord
sender.is_a?(User)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
+1 -1
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :string
# url :text
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
+11
View File
@@ -95,6 +95,7 @@ class MailPresenter < SimpleDelegator
content_type: content_type,
date: date,
from: from,
headers: headers_data,
html_content: html_content,
in_reply_to: in_reply_to,
message_id: message_id,
@@ -136,6 +137,16 @@ class MailPresenter < SimpleDelegator
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
end
def headers_data
headers = {
'x-original-from' => @mail['X-Original-From']&.value,
'x-original-sender' => @mail['X-Original-Sender']&.value,
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
}.compact
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
@@ -0,0 +1,9 @@
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
def up
change_column :webhooks, :url, :text
end
def down
change_column :webhooks, :url, :string
end
end
+2 -2
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: 2025_11_19_161025) do
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
create_table "webhooks", force: :cascade do |t|
t.integer "account_id"
t.integer "inbox_id"
t.string "url"
t.text "url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
+2 -1
View File
@@ -14,8 +14,9 @@ RSpec.describe Message do
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
# Captain::Assistant responses clear waiting_since (like AgentBot)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
expect(conversation.waiting_since).to be_nil
create(:message, message_type: :outgoing, conversation: conversation)
+111
View File
@@ -82,4 +82,115 @@ describe AgentBotListener do
end
end
end
describe '#conversation_created' do
let(:event_name) { 'conversation.created' }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_created(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
conversation.webhook_data.merge(event: 'conversation_created')).once
listener.conversation_created(event)
end
end
end
describe '#conversation_updated' do
let(:event_name) { 'conversation.updated' }
let!(:event) do
Events::Base.new(
event_name,
Time.zone.now,
conversation: conversation.reload,
changed_attributes: {
custom_attributes: [{ test: nil }, { test: 'testing custom attri webhook' }]
}
)
end
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_updated(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update(custom_attributes: { test: 'testing custom attri webhook' })
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_updated',
changed_attributes: [
{
custom_attributes: {
previous_value: { test: nil },
current_value: { test: 'testing custom attri webhook' }
}
}
]
)
).once
listener.conversation_updated(event)
end
end
end
describe '#conversation_status_changed' do
let(:event_name) { 'conversation.status_changed' }
let!(:event) do
Events::Base.new(
event_name,
Time.zone.now,
conversation: conversation.reload,
changed_attributes: {
status: %w[pending open]
}
)
end
context 'when agent bot is not configured' do
it 'does not send message to agent bot' do
expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_status_changed(event)
end
end
context 'when agent bot is configured' do
it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update(status: 'open')
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
conversation.webhook_data.merge(
event: 'conversation_status_changed',
changed_attributes: [
{
status: {
previous_value: 'pending',
current_value: 'open'
}
}
]
)
).once
listener.conversation_status_changed(event)
end
end
end
end
+3 -3
View File
@@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content
to auto_reply]
end
context 'with reply uuid present' do
@@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+65
View File
@@ -978,5 +978,70 @@ RSpec.describe Conversation do
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
context 'when AgentBot responds between customer messages' do
let(:agent_bot) { create(:agent_bot, account: account) }
def create_bot_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: agent_bot,
created_at: created_at)
end
message
end
it 'calculates reply time from the most recent customer message after bot response' do
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 5.hours.ago)
# Bot responds
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after bot response) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies - should create reply_time event from customer message 2
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
it 'handles multiple bot responses before customer messages again' do
# Initial conversation: customer message -> agent first reply
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 6.hours.ago)
# Bot responds multiple times
create_bot_message(conversation, created_at: 5.hours.ago)
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after multiple bot responses) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
end
end
end
+57
View File
@@ -300,6 +300,63 @@ RSpec.describe Message do
expect(conversation.waiting_since).to eq old_waiting_since
end
context 'when bot has responded to the conversation' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
before do
# Create initial customer message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 2.hours.ago)
conversation.update(waiting_since: 2.hours.ago)
# Bot responds
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 1.hour.ago)
end
it 'resets waiting_since when customer sends a new message after bot response' do
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'does not reset waiting_since if last response was from human agent' do
# Human agent responds (clears waiting_since)
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent)
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends new message
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'clears waiting_since when bot responds' do
# After the bot response in before block, waiting_since should already be cleared
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends another message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 30.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
# Another bot response should clear it again
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 15.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
end
end
context 'with webhook_data' do
+34
View File
@@ -41,6 +41,7 @@ RSpec.describe MailPresenter do
:content_type,
:date,
:from,
:headers,
:html_content,
:in_reply_to,
:message_id,
@@ -60,6 +61,39 @@ RSpec.describe MailPresenter do
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'includes forwarded headers in serialized_data' do
mail_with_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['X-Original-From'] = 'Original <original@example.com>'
header['X-Original-Sender'] = 'original@example.com'
header['X-Forwarded-For'] = 'forwarder@example.com'
end
data = described_class.new(mail_with_headers).serialized_data
expect(data[:headers]).to eq(
'x-original-from' => 'Original <original@example.com>',
'x-original-sender' => 'original@example.com',
'x-forwarded-for' => 'forwarder@example.com'
)
end
it 'returns nil headers when forwarding headers are missing' do
mail_without_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
data = described_class.new(mail_without_headers).serialized_data
expect(data[:headers]).to be_nil
end
it 'give email from in downcased format' do
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
end