feat: sign webhooks for API channel and agentbots (#13892)

Account webhooks sign outgoing payloads with HMAC-SHA256, but agent bot
and API inbox webhooks were delivered unsigned. This PR adds the same
signing to both.

Each model gets a dedicated `secret` column rather than reusing the
agent bot's `access_token` (for API auth back into Chatwoot) or the API
inbox's `hmac_token` (for inbound contact identity verification). These
serve different trust boundaries and shouldn't be coupled — rotating a
signing secret shouldn't invalidate API access or contact verification.

The existing `Webhooks::Trigger` already signs when a secret is present,
so the backend change is just passing `secret:` through to the jobs.
Shared token logic is extracted into a `WebhookSecretable` concern
included by `Webhook`, `AgentBot`, and `Channel::Api`. The frontend
reuses the existing `AccessToken` component for secret display. Secrets
are admin-only and excluded from enterprise audit logs.

### How to test

Point an agent bot or API inbox webhook URL at a request inspector. Send
a message and verify `X-Chatwoot-Signature` and `X-Chatwoot-Timestamp`
headers are present. Reset the secret from settings and confirm
subsequent deliveries use the new value.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Shivam Mishra 2026-04-06 15:28:25 +05:30 committed by GitHub
parent f4d66566d0
commit 95463230cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 273 additions and 28 deletions

View File

@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
@agent_bot.reload @agent_bot.reload
end end
def reset_secret
@agent_bot.reset_secret!
end
private private
def agent_bot def agent_bot

View File

@ -66,6 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
head :ok head :ok
end end
def reset_secret
return head :not_found unless @inbox.api?
@inbox.channel.reset_secret!
end
def destroy def destroy
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }

View File

@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient {
resetAccessToken(botId) { resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`); return axios.post(`${this.url}/${botId}/reset_access_token`);
} }
resetSecret(botId) {
return axios.post(`${this.url}/${botId}/reset_secret`);
}
} }
export default new AgentBotsAPI(); export default new AgentBotsAPI();

View File

@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
template, template,
}); });
} }
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
} }
export default new Inboxes(); export default new Inboxes();

View File

@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again." "ERROR_MESSAGE": "Could not update bot. Please try again."
} }
}, },
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "Done",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely", "DESCRIPTION": "Copy the access token and save it securely",

View File

@ -86,6 +86,14 @@
"PLACEHOLDER": "Please enter your Webhook URL", "PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "Please enter a valid URL" "ERROR": "Please enter a valid URL"
}, },
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": { "CHANNEL_DOMAIN": {
"LABEL": "Website Domain", "LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)" "PLACEHOLDER": "Enter your website domain (eg: acme.com)"

View File

@ -47,6 +47,7 @@ const formState = reactive({
const [showAccessToken, toggleAccessToken] = useToggle(); const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref(''); const accessToken = ref('');
const botSecret = ref('');
const v$ = useVuelidate( const v$ = useVuelidate(
{ {
@ -179,15 +180,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'); : t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey); useAlert(alertKey);
// Show access token after creation // Show access token and secret after creation
if (isCreate) { if (isCreate) {
const { access_token: responseAccessToken, id } = response || {}; const {
access_token: responseAccessToken,
secret: responseSecret,
id,
} = response || {};
if (id && responseAccessToken) { if (id && responseAccessToken) {
accessToken.value = responseAccessToken; accessToken.value = responseAccessToken;
botSecret.value = responseSecret || '';
toggleAccessToken(true); toggleAccessToken(true);
} else { } else {
accessToken.value = ''; accessToken.value = '';
botSecret.value = '';
dialogRef.value.close(); dialogRef.value.close();
} }
} else { } else {
@ -212,14 +219,16 @@ const initializeForm = () => {
thumbnail, thumbnail,
bot_config: botConfig, bot_config: botConfig,
access_token: botAccessToken, access_token: botAccessToken,
secret: botSecretValue,
} = props.selectedBot; } = props.selectedBot;
formState.botName = name || ''; formState.botName = name || '';
formState.botDescription = description || ''; formState.botDescription = description || '';
formState.botUrl = botUrl || botConfig?.webhook_url || ''; formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || ''; formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) { if (props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken; if (botAccessToken) accessToken.value = botAccessToken;
if (botSecretValue) botSecret.value = botSecretValue;
} }
} else { } else {
resetForm(); resetForm();
@ -231,6 +240,24 @@ const onCopyToken = async value => {
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL')); useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
}; };
const onCopySecret = async value => {
await copyTextToClipboard(value || botSecret.value);
useAlert(t('AGENT_BOTS.SECRET.COPY_SUCCESS'));
};
const onResetSecret = async () => {
const response = await store.dispatch(
'agentBots/resetSecret',
props.selectedBot.id
);
if (response) {
botSecret.value = response.secret;
useAlert(t('AGENT_BOTS.SECRET.RESET_SUCCESS'));
} else {
useAlert(t('AGENT_BOTS.SECRET.RESET_ERROR'));
}
};
const onResetToken = async () => { const onResetToken = async () => {
const response = await store.dispatch( const response = await store.dispatch(
'agentBots/resetAccessToken', 'agentBots/resetAccessToken',
@ -247,6 +274,7 @@ const onResetToken = async () => {
const closeModal = () => { const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset(); if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = ''; accessToken.value = '';
botSecret.value = '';
toggleAccessToken(false); toggleAccessToken(false);
}; };
@ -318,6 +346,20 @@ defineExpose({ dialogRef });
/> />
</div> </div>
<div
v-if="botSecret && type === MODAL_TYPES.EDIT"
class="flex flex-col gap-1"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
</label>
<AccessToken
:value="botSecret"
@on-copy="onCopySecret"
@on-reset="onResetSecret"
/>
</div>
<div v-if="showAccessTokenInput" class="flex flex-col gap-1"> <div v-if="showAccessTokenInput" class="flex flex-col gap-1">
<label <label
v-if="type === MODAL_TYPES.EDIT" v-if="type === MODAL_TYPES.EDIT"
@ -339,6 +381,23 @@ defineExpose({ dialogRef });
/> />
</div> </div>
<div
v-if="botSecret && showAccessToken && type === MODAL_TYPES.CREATE"
class="flex flex-col gap-1"
>
<p class="text-sm text-n-slate-11">
{{ $t('AGENT_BOTS.SECRET.CREATED_DESC') }}
</p>
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.SECRET.LABEL') }}
</label>
<AccessToken
:value="botSecret"
:show-reset-button="false"
@on-copy="onCopySecret"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2"> <div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton <NextButton
faded faded

View File

@ -38,6 +38,8 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue'; import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
import SelectInput from 'dashboard/components-next/select/Select.vue'; import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue'; import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default { export default {
components: { components: {
@ -69,6 +71,7 @@ export default {
SelectInput, SelectInput,
AccountHealth, AccountHealth,
Widget, Widget,
AccessToken,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@ -362,6 +365,33 @@ export default {
this.fetchSharedData(); this.fetchSharedData();
}, },
methods: { methods: {
async copyWebhookSecret(value) {
await copyTextToClipboard(value);
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.COPY_SUCCESS'
)
);
},
async resetWebhookSecret() {
const response = await this.$store.dispatch(
'inboxes/resetSecret',
this.inbox.id
);
if (response) {
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_SUCCESS'
)
);
} else {
useAlert(
this.$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.RESET_ERROR'
)
);
}
},
fetchSharedData() { fetchSharedData() {
this.$store.dispatch('agents/get'); this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get'); this.$store.dispatch('teams/get');
@ -714,6 +744,21 @@ export default {
/> />
</SettingsFieldSection> </SettingsFieldSection>
<SettingsFieldSection
v-if="isAPIInbox && inbox.secret"
:label="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_SECRET.LABEL'
)
"
>
<AccessToken
:value="inbox.secret"
@on-copy="copyWebhookSecret"
@on-reset="resetWebhookSecret"
/>
</SettingsFieldSection>
<SettingsFieldSection <SettingsFieldSection
v-if="isAWebWidgetInbox" v-if="isAWebWidgetInbox"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')" :label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"

View File

@ -183,6 +183,17 @@ export const actions = {
return null; return null;
} }
}, },
resetSecret: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetSecret(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
}; };
export const mutations = { export const mutations = {

View File

@ -366,6 +366,16 @@ export const actions = {
); );
return response.data; return response.data;
}, },
resetSecret: async ({ commit }, inboxId) => {
try {
const response = await InboxesAPI.resetSecret(inboxId);
commit(types.default.EDIT_INBOXES, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
}; };
export const mutations = { export const mutations = {

View File

@ -2,11 +2,13 @@ class AgentBots::WebhookJob < WebhookJob
queue_as :high queue_as :high
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error| retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
url, payload, webhook_type = job.arguments url, payload, webhook_type = job.arguments
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook).handle_failure(error) kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
delivery_id: kwargs[:delivery_id]).handle_failure(error)
end end
def perform(url, payload, webhook_type = :agent_bot_webhook) def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
super(url, payload, webhook_type) super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}") Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise raise

View File

@ -76,6 +76,7 @@ class AgentBotListener < BaseListener
def process_webhook_bot_event(agent_bot, payload) def process_webhook_bot_event(agent_bot, payload)
return if agent_bot.outgoing_url.blank? return if agent_bot.outgoing_url.blank?
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload) AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload, :agent_bot_webhook,
secret: agent_bot.secret, delivery_id: SecureRandom.uuid)
end end
end end

View File

@ -122,7 +122,7 @@ class WebhookListener < BaseListener
return if inbox.channel.webhook_url.blank? return if inbox.channel.webhook_url.blank?
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook, WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
delivery_id: SecureRandom.uuid) secret: inbox.channel.secret, delivery_id: SecureRandom.uuid)
end end
def deliver_webhook_payloads(payload, inbox) def deliver_webhook_payloads(payload, inbox)

View File

@ -8,6 +8,7 @@
# description :string # description :string
# name :string # name :string
# outgoing_url :string # outgoing_url :string
# secret :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint # account_id :bigint
@ -21,6 +22,8 @@ class AgentBot < ApplicationRecord
include AccessTokenable include AccessTokenable
include Avatarable include Avatarable
include WebhookSecretable
scope :accessible_to, lambda { |account| scope :accessible_to, lambda { |account|
account_id = account&.id account_id = account&.id
where(account_id: [nil, account_id]) where(account_id: [nil, account_id])
@ -63,3 +66,5 @@ class AgentBot < ApplicationRecord
account.nil? account.nil?
end end
end end
AgentBot.include_mod_with('Audit::AgentBot')

View File

@ -7,6 +7,7 @@
# hmac_mandatory :boolean default(FALSE) # hmac_mandatory :boolean default(FALSE)
# hmac_token :string # hmac_token :string
# identifier :string # identifier :string
# secret :string
# webhook_url :string # webhook_url :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
@ -26,6 +27,7 @@ class Channel::Api < ApplicationRecord
has_secure_token :identifier has_secure_token :identifier
has_secure_token :hmac_token has_secure_token :hmac_token
include WebhookSecretable
validate :ensure_valid_agent_reply_time_window validate :ensure_valid_agent_reply_time_window
validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT } validates :webhook_url, length: { maximum: Limits::URL_LENGTH_LIMIT }

View File

@ -0,0 +1,13 @@
module WebhookSecretable
extend ActiveSupport::Concern
included do
has_secure_token :secret
encrypts :secret if Chatwoot.encryption_configured?
end
def reset_secret!
regenerate_secret
reload
end
end

View File

@ -22,8 +22,7 @@ class Webhook < ApplicationRecord
belongs_to :account belongs_to :account
belongs_to :inbox, optional: true belongs_to :inbox, optional: true
has_secure_token :secret include WebhookSecretable
encrypts :secret if Chatwoot.encryption_configured?
validates :account_id, presence: true validates :account_id, presence: true
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])

View File

@ -26,4 +26,8 @@ class AgentBotPolicy < ApplicationPolicy
def reset_access_token? def reset_access_token?
@account_user.administrator? @account_user.administrator?
end end
def reset_secret?
@account_user.administrator?
end
end end

View File

@ -65,4 +65,8 @@ class InboxPolicy < ApplicationPolicy
def health? def health?
@account_user.administrator? @account_user.administrator?
end end
def reset_secret?
@account_user.administrator?
end
end end

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/agent_bot', formats: [:json], resource: AgentBotPresenter.new(@agent_bot)

View File

@ -0,0 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox

View File

@ -7,4 +7,5 @@ json.bot_type resource.bot_type
json.bot_config resource.bot_config json.bot_config resource.bot_config
json.account_id resource.account_id json.account_id resource.account_id
json.access_token resource.access_token if resource.access_token.present? json.access_token resource.access_token if resource.access_token.present?
json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator?
json.system_bot resource.system_bot? json.system_bot resource.system_bot?

View File

@ -113,6 +113,7 @@ end
## API Channel Attributes ## API Channel Attributes
if resource.api? if resource.api?
json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator? json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator?
json.secret resource.channel.try(:secret) if Current.account_user&.administrator?
json.webhook_url resource.channel.try(:webhook_url) json.webhook_url resource.channel.try(:webhook_url)
json.inbox_identifier resource.channel.try(:identifier) json.inbox_identifier resource.channel.try(:identifier)
json.additional_attributes resource.channel.try(:additional_attributes) json.additional_attributes resource.channel.try(:additional_attributes)

View File

@ -88,6 +88,7 @@ Rails.application.routes.draw do
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member delete :avatar, on: :member
post :reset_access_token, on: :member post :reset_access_token, on: :member
post :reset_secret, on: :member
end end
resources :contact_inboxes, only: [] do resources :contact_inboxes, only: [] do
collection do collection do
@ -221,6 +222,7 @@ Rails.application.routes.draw do
post :sync_templates, on: :member post :sync_templates, on: :member
get :health, on: :member get :health, on: :member
post :register_webhook, on: :member post :register_webhook, on: :member
post :reset_secret, on: :member
if ChatwootApp.enterprise? if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member get :token, on: :member

View File

@ -0,0 +1,5 @@
class AddSecretToAgentBots < ActiveRecord::Migration[7.1]
def change
add_column :agent_bots, :secret, :string
end
end

View File

@ -0,0 +1,5 @@
class AddSecretToChannelApi < ActiveRecord::Migration[7.1]
def change
add_column :channel_api, :secret, :string
end
end

View File

@ -0,0 +1,15 @@
class BackfillAgentBotAndChannelApiSecrets < ActiveRecord::Migration[7.1]
def up
AgentBot.where(secret: nil).find_each do |agent_bot|
agent_bot.update!(secret: SecureRandom.urlsafe_base64(24))
end
Channel::Api.where(secret: nil).find_each do |channel|
channel.update!(secret: SecureRandom.urlsafe_base64(24))
end
end
def down
# no-op: removing the columns in the previous migrations handles cleanup
end
end

View File

@ -131,6 +131,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
t.bigint "account_id" t.bigint "account_id"
t.integer "bot_type", default: 0 t.integer "bot_type", default: 0
t.jsonb "bot_config", default: {} t.jsonb "bot_config", default: {}
t.string "secret"
t.index ["account_id"], name: "index_agent_bots_on_account_id" t.index ["account_id"], name: "index_agent_bots_on_account_id"
end end
@ -413,6 +414,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do
t.string "hmac_token" t.string "hmac_token"
t.boolean "hmac_mandatory", default: false t.boolean "hmac_mandatory", default: false
t.jsonb "additional_attributes", default: {} t.jsonb "additional_attributes", default: {}
t.string "secret"
t.index ["hmac_token"], name: "index_channel_api_on_hmac_token", unique: true t.index ["hmac_token"], name: "index_channel_api_on_hmac_token", unique: true
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
end end

View File

@ -0,0 +1,7 @@
module Enterprise::Audit::AgentBot
extend ActiveSupport::Concern
included do
audited associated_with: :account, except: [:secret]
end
end

View File

@ -17,7 +17,7 @@ module Enterprise::Channelable
auditable_id = inbox.id auditable_id = inbox.id
auditable_type = 'Inbox' auditable_type = 'Inbox'
audited_changes = saved_changes.except('updated_at') audited_changes = saved_changes.except('updated_at', 'secret')
return if audited_changes.blank? return if audited_changes.blank?

View File

@ -25,8 +25,10 @@ describe AgentBotListener do
context 'when agent bot is configured' do context 'when agent bot is configured' do
it 'sends message to agent bot' do it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, expect(AgentBots::WebhookJob).to receive(:perform_later).with(
message.webhook_data.merge(event: 'message_created')).once agent_bot.outgoing_url, message.webhook_data.merge(event: 'message_created'),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.message_created(event) listener.message_created(event)
end end
@ -48,8 +50,14 @@ describe AgentBotListener do
it 'sends message to both bots exactly once' do it 'sends message to both bots exactly once' do
payload = message.webhook_data.merge(event: 'message_created') payload = message.webhook_data.merge(event: 'message_created')
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, payload).once expect(AgentBots::WebhookJob).to receive(:perform_later).with(
expect(AgentBots::WebhookJob).to receive(:perform_later).with(conversation_bot.outgoing_url, payload).once agent_bot.outgoing_url, payload, :agent_bot_webhook,
secret: agent_bot.secret, delivery_id: instance_of(String)
).once
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
conversation_bot.outgoing_url, payload, :agent_bot_webhook,
secret: conversation_bot.secret, delivery_id: instance_of(String)
).once
listener.message_created(event) listener.message_created(event)
end end
@ -74,9 +82,11 @@ describe AgentBotListener do
it 'sends webhook to the inbox agent bot with changed_attributes' do it 'sends webhook to the inbox agent bot with changed_attributes' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, expect(AgentBots::WebhookJob).to receive(:perform_later).with(
conversation.webhook_data.merge(event: 'conversation_updated', agent_bot.outgoing_url,
changed_attributes: nil)).once conversation.webhook_data.merge(event: 'conversation_updated', changed_attributes: nil),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.conversation_updated(event) listener.conversation_updated(event)
end end
end end
@ -93,11 +103,14 @@ describe AgentBotListener do
it 'sends webhook with changed_attributes to the assigned agent bot' do it 'sends webhook with changed_attributes to the assigned agent bot' do
expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }] expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }]
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, expect(AgentBots::WebhookJob).to receive(:perform_later).with(
conversation.webhook_data.merge( agent_bot.outgoing_url,
event: 'conversation_updated', conversation.webhook_data.merge(
changed_attributes: expected_changed_attributes event: 'conversation_updated',
)).once changed_attributes: expected_changed_attributes
),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once
listener.conversation_updated(event) listener.conversation_updated(event)
end end
end end
@ -121,7 +134,8 @@ describe AgentBotListener do
expect(AgentBots::WebhookJob).to receive(:perform_later) expect(AgentBots::WebhookJob).to receive(:perform_later)
.with( .with(
agent_bot.outgoing_url, agent_bot.outgoing_url,
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' }) conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' }),
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
).once ).once
listener.webwidget_triggered(event) listener.webwidget_triggered(event)

View File

@ -59,7 +59,7 @@ describe WebhookListener do
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
expect(WebhookJob).to receive(:perform_later).with( expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, delivery_id: instance_of(String) :api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once ).once
listener.message_created(api_event) listener.message_created(api_event)
end end
@ -112,7 +112,7 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with( expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, channel_api.webhook_url,
api_conversation.webhook_data.merge(event: 'conversation_created'), api_conversation.webhook_data.merge(event: 'conversation_created'),
:api_inbox_webhook, delivery_id: instance_of(String) :api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once ).once
listener.conversation_created(api_event) listener.conversation_created(api_event)
end end
@ -348,7 +348,7 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with( expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, payload, :api_inbox_webhook, channel_api.webhook_url, payload, :api_inbox_webhook,
delivery_id: instance_of(String) secret: channel_api.secret, delivery_id: instance_of(String)
).once ).once
listener.conversation_typing_on(api_event) listener.conversation_typing_on(api_event)
end end