Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8c9e4453 | ||
|
|
566de02385 | ||
|
|
02ab856520 | ||
|
|
e58600d1b9 |
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user