Merge branch 'develop' into chore/improve-conversation-snooze

This commit is contained in:
Sivin Varghese
2026-02-26 21:43:42 +05:30
committed by GitHub
61 changed files with 895 additions and 145 deletions
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
+1
View File
@@ -11,6 +11,7 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
@@ -129,6 +129,7 @@ const props = defineProps({
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
@@ -172,7 +173,10 @@ const variant = computed(() => {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
const isBot =
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
props.senderType === SENDER_TYPES.AGENT_BOT ||
(!props.sender && !props.additionalAttributes?.senderName);
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
}
@@ -450,12 +454,13 @@ const avatarInfo = computed(() => {
};
}
// If no sender, return bot info
// If no sender, check for Slack (or other integration) sender info
if (!props.sender) {
return {
name: t('CONVERSATION.BOT'),
src: '',
};
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
if (senderName) {
return { name: senderName, src: senderAvatarUrl ?? '' };
}
return { name: t('CONVERSATION.BOT'), src: '' };
}
const { sender } = props;
@@ -380,7 +380,11 @@ export default {
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
v-if="
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId"
/>
<transition name="modal-fade">
@@ -86,6 +86,10 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
value: 'priority_desc_created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
@@ -85,7 +85,12 @@ export default {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
const status = error?.response?.status;
if (status === 402) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
} else {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
}
} finally {
this.isSubmitting = false;
}
@@ -200,9 +200,13 @@ export default {
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
if (this.isAPIInbox) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
}
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
@@ -427,7 +431,7 @@ export default {
},
isEditorDisabled() {
return (
this.isAWhatsAppChannel &&
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
@@ -21,6 +21,7 @@ export default {
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
-3
View File
@@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
AUDIT_LOGS: 'audit_logs',
INBOX_VIEW: 'inbox_view',
SLA: 'sla',
RESPONSE_BOT: 'response_bot',
CHANNEL_EMAIL: 'channel_email',
CHANNEL_FACEBOOK: 'channel_facebook',
CHANNEL_TWITTER: 'channel_twitter',
CHANNEL_WEBSITE: 'channel_website',
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
@@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
@@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
@@ -76,6 +76,9 @@
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
}
},
"ATTACHMENTS": {
@@ -192,6 +192,7 @@
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
@@ -305,6 +306,7 @@
"CANCEL": "Cancel",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again",
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
"FORM": {
"SEND_TO_CONTACT": "Send the transcript to the customer",
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
@@ -31,6 +31,14 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
"DONE": "Done"
},
"COUNT": "{n} webhook | {n} webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search",
@@ -58,6 +58,7 @@ export default {
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
@@ -1,60 +1,98 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import WebhookForm from './WebhookForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { WebhookForm },
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
},
const props = defineProps({
onClose: {
type: Function,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
const createdWebhook = ref(null);
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
const onSubmit = async webhook => {
try {
const result = await store.dispatch('webhooks/create', { webhook });
createdWebhook.value = result;
} catch (error) {
const message =
error.response.data.message ||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
};
const handleCopySecret = async () => {
await copyTextToClipboard(createdWebhook.value.secret);
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
};
</script>
<template>
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
<template v-if="createdWebhook">
<woot-modal-header
:header-title="
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
"
/>
<div class="px-8 pb-6">
<p class="text-sm text-n-slate-11 mb-4">
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
</p>
<label>
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="createdWebhook.secret"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
icon="i-lucide-copy"
slate
faded
@click="handleCopySecret"
/>
</div>
</label>
<div class="flex justify-end mt-4">
<NextButton
blue
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
@click="props.onClose()"
/>
</div>
</div>
</template>
<template v-else>
<woot-modal-header
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="props.onClose()"
/>
</template>
</div>
</template>
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -57,10 +59,14 @@ export default {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
hasSecret() {
return !!this.value.secret;
},
webhookURLInputPlaceholder() {
return this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
@@ -81,6 +87,10 @@ export default {
subscriptions: this.subscriptions,
});
},
async copySecret() {
await copyTextToClipboard(this.value.secret);
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
},
getI18nKey,
},
};
@@ -111,6 +121,35 @@ export default {
:placeholder="webhookNameInputPlaceholder"
/>
</label>
<label v-if="hasSecret" class="mb-4">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
type="button"
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
slate
faded
@click="secretVisible = !secretVisible"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
type="button"
icon="i-lucide-copy"
slate
faded
@click="copySecret"
/>
</div>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>
@@ -457,11 +457,7 @@ const actions = {
},
sendEmailTranscript: async (_, { conversationId, email }) => {
try {
await ConversationApi.sendEmailTranscript({ conversationId, email });
} catch (error) {
throw new Error(error);
}
await ConversationApi.sendEmailTranscript({ conversationId, email });
},
updateCustomAttributes: async (
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
@@ -139,6 +140,14 @@ const sortConfig = {
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnPriorityCreatedAt: (a, b) => {
const DEFAULT_FOR_NULL = 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
if (p1 !== p2) return p2 - p1;
return a.created_at - b.created_at;
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
@@ -42,6 +42,7 @@ export const actions = {
} = response.data;
commit(types.default.ADD_WEBHOOK, webhook);
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
return webhook;
} catch (error) {
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
throw error;
@@ -72,6 +72,10 @@ export default {
return this.message.sender.available_name || this.message.sender.name;
}
if (this.message.additional_attributes?.sender_name) {
return this.message.additional_attributes.sender_name;
}
if (this.useInboxAvatarForBot) {
return this.channelConfig.websiteName;
}
@@ -87,9 +91,13 @@ export default {
return displayImage;
}
return this.message.sender
? this.message.sender.avatar_url
: displayImage;
if (this.message.sender) {
return this.message.sender.avatar_url;
}
return (
this.message.additional_attributes?.sender_avatar_url || displayImage
);
},
hasRecordedResponse() {
return (
@@ -15,6 +15,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return false if inbox.channel.in_backoff?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)
+16 -13
View File
@@ -6,26 +6,29 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def perform(channel, interval = 1)
return unless should_fetch_email?(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel, interval)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
fetch_emails_with_backoff(channel, interval)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
private
def fetch_emails_with_backoff(channel, interval)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) }
channel.clear_backoff!
rescue Imap::AuthenticationError => e
Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}"
channel.authorization_error!
rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e
Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}"
channel.apply_backoff!
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
end
def should_fetch_email?(channel)
channel.imap_enabled? && !channel.reauthorization_required?
channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff?
end
def process_email_for_channel(channel, interval)
+2 -2
View File
@@ -1,7 +1,7 @@
class WebhookJob < ApplicationJob
queue_as :medium
# There are 3 types of webhooks, account, inbox and agent_bot
def perform(url, payload, webhook_type = :account_webhook)
Webhooks::Trigger.execute(url, payload, webhook_type)
def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
end
end
+5 -2
View File
@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
WebhookJob.perform_later(webhook.url, payload)
WebhookJob.perform_later(webhook.url, payload, :account_webhook,
secret: webhook.secret,
delivery_id: SecureRandom.uuid)
end
end
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
return unless inbox.channel_type == 'Channel::Api'
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)
end
def deliver_webhook_payloads(payload, inbox)
+4
View File
@@ -72,6 +72,10 @@ class Channel::Email < ApplicationRecord
imap_enabled && imap_address == 'imap.gmail.com'
end
def backoff_log_identifier
"Error for email channel - #{inbox.id}"
end
private
def ensure_forward_to_email
+70
View File
@@ -0,0 +1,70 @@
# Backoffable provides transient-error retry backoff for models that depend on external services.
#
# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!.
# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that
# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!.
#
# Call clear_backoff! after a successful operation to reset the counter.
module Backoffable
extend ActiveSupport::Concern
def backoff_log_identifier
inbox_id = respond_to?(:inbox) && inbox&.id
inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}"
end
def backoff_retry_count
::Redis::Alfred.get(backoff_retry_count_key).to_i
end
def in_backoff?
val = ::Redis::Alfred.get(backoff_retry_after_key)
val.present? && Time.zone.at(val.to_f) > Time.current
end
def apply_backoff!
new_count = backoff_retry_count + 1
max_interval, max_retries = backoff_limits
if new_count > max_retries
exhaust_backoff(new_count)
else
schedule_backoff_retry(new_count, max_interval, max_retries)
end
end
def clear_backoff!
::Redis::Alfred.delete(backoff_retry_count_key)
::Redis::Alfred.delete(backoff_retry_after_key)
end
private
def backoff_limits
max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i
max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i
[max_interval, (max_interval - 1) + max_count]
end
def exhaust_backoff(new_count)
Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization"
clear_backoff!
prompt_reauthorization!
end
def schedule_backoff_retry(new_count, max_interval, max_retries)
wait_minutes = [new_count, max_interval].min
::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours)
::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours)
Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m"
end
def backoff_retry_count_key
format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
end
def backoff_retry_after_key
format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id)
end
end
+3
View File
@@ -13,6 +13,8 @@
module Reauthorizable
extend ActiveSupport::Concern
include Backoffable
AUTHORIZATION_ERROR_THRESHOLD = 2
# model attribute
@@ -65,6 +67,7 @@ module Reauthorizable
def reauthorized!
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
clear_backoff!
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
+4
View File
@@ -14,6 +14,10 @@ module SortHandler
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, last_activity_at DESC"))
end
def sort_on_priority_created_at(sort_direction = :desc)
order(generate_sql_query("priority #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
end
def sort_on_waiting_since(sort_direction = :asc)
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
end
+3
View File
@@ -21,6 +21,9 @@ class Webhook < ApplicationRecord
belongs_to :account
belongs_to :inbox, optional: true
has_secure_token :secret
encrypts :secret if Chatwoot.encryption_configured?
validates :account_id, presence: true
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
validate :validate_webhook_subscriptions
@@ -0,0 +1 @@
class Imap::AuthenticationError < StandardError; end
@@ -107,7 +107,11 @@ class Imap::BaseFetchEmailService
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
imap.authenticate(authentication_type, channel.imap_login, imap_password)
begin
imap.authenticate(authentication_type, channel.imap_login, imap_password)
rescue Net::IMAP::NoResponseError => e
raise Imap::AuthenticationError, e.message
end
imap.select('INBOX')
imap
end
@@ -3,6 +3,7 @@ json.name webhook.name
json.url webhook.url
json.account_id webhook.account_id
json.subscriptions webhook.subscriptions
json.secret webhook.secret
if webhook.inbox
json.inbox do
json.id webhook.inbox.id
+1 -3
View File
@@ -108,12 +108,10 @@
- name: response_bot
display_name: Response Bot
enabled: false
premium: true
deprecated: true
- name: message_reply_to
display_name: Message Reply To
enabled: false
help_url: https://chwt.app/hc/reply-to
deprecated: true
- name: insert_article_in_reply
display_name: Insert Article in Reply
@@ -149,7 +147,7 @@
enabled: true
- name: report_v4
display_name: Report V4
enabled: true
enabled: false
deprecated: true
- name: contact_chatwoot_support_team
display_name: Contact Chatwoot Support Team
+13
View File
@@ -96,6 +96,19 @@
locked: false
# ------- End of Account Related Config ------- #
# ------- Transient Error Backoff Config ------- #
- name: BACKOFF_MAX_INTERVAL_MINUTES
display_title: 'Backoff Max Interval (minutes)'
description: 'Maximum wait time in minutes between retry attempts before the backoff plateaus'
value: 5
locked: false
- name: BACKOFF_MAX_INTERVAL_COUNT
display_title: 'Backoff Max Retry Count'
description: 'Number of additional retries at the maximum interval before prompting reauthorization'
value: 10
locked: false
# ------- End of Transient Error Backoff Config ------- #
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
display_title: 'Inbound Email Domain'
@@ -0,0 +1,5 @@
class AddSecretToWebhooks < ActiveRecord::Migration[7.1]
def change
add_column :webhooks, :secret, :string
end
end
@@ -0,0 +1,11 @@
class BackfillWebhookSecrets < ActiveRecord::Migration[7.1]
def up
Webhook.find_each do |webhook|
webhook.update!(secret: SecureRandom.urlsafe_base64(24))
end
end
def down
# no-op: removing the column in the previous migration handles cleanup
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1250,6 +1250,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
t.integer "webhook_type", default: 0
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"]
t.string "name"
t.string "secret"
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end
@@ -71,6 +71,7 @@ module Concerns::Toolable
add_base_headers(headers, state)
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
add_contact_headers(headers, state[:contact]) if state[:contact]
add_contact_inbox_headers(headers, state[:contact_inbox])
end
end
@@ -91,6 +92,11 @@ module Concerns::Toolable
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
end
def add_contact_inbox_headers(headers, contact_inbox)
headers['X-Chatwoot-Contact-Inbox-Id'] = contact_inbox[:id].to_s if contact_inbox&.[](:id)
headers['X-Chatwoot-Contact-Inbox-Verified'] = (contact_inbox&.[](:hmac_verified) || false).to_s
end
def format_response(raw_response_body)
return raw_response_body if response_template.blank?
@@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook
extend ActiveSupport::Concern
included do
audited associated_with: :account
audited associated_with: :account, except: [:secret]
end
end
@@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService
custom_attributes additional_attributes
].freeze
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
@@ -127,16 +129,21 @@ class Captain::Assistant::AgentRunnerService
assistant_config: @assistant.config
}
if @conversation
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
end
build_conversation_state(state) if @conversation
state
end
def build_conversation_state(state)
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox
state[:contact_inbox] =
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
end
def build_and_wire_agents
assistant_agent = @assistant.agent
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
@@ -11,7 +11,6 @@ class Enterprise::Billing::HandleStripeEventService
help_center
campaigns
team_management
channel_twitter
channel_facebook
channel_email
channel_instagram
-1
View File
@@ -1,7 +1,6 @@
# List of the premium features in EE edition
- disable_branding
- audit_logs
- response_bot
- sla
- custom_roles
- captain_integration
@@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc
{% endif -%}
{% if campaign.id -%}
{% render 'campaign' %}
{% render 'campaign', campaign: campaign %}
{% endif -%}
{% endif -%}
@@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc
{% endif -%}
{% if campaign.id -%}
{% render 'campaign' %}
{% render 'campaign', campaign: campaign %}
{% endif -%}
{% endif -%}
+6
View File
@@ -16,4 +16,10 @@ module ExceptionList
Errno::ECONNRESET, Errno::ENETUNREACH, Net::IMAP::ByeResponseError,
SocketError
].freeze
IMAP_TRANSIENT_EXCEPTIONS = (IMAP_EXCEPTIONS + [
EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError,
Net::IMAP::InvalidResponseError, Net::IMAP::ResponseParseError,
Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError
]).freeze
end
+22 -4
View File
@@ -27,6 +27,10 @@ module Integrations::Slack::SlackMessageHelper
end
def create_message
resolved_sender, sender_name, sender_avatar_url = resolve_slack_sender
slack_sender_attrs = {}
slack_sender_attrs[:sender_name] = sender_name if sender_name
slack_sender_attrs[:sender_avatar_url] = sender_avatar_url if sender_avatar_url
@message = conversation.messages.build(
message_type: :outgoing,
account_id: conversation.account_id,
@@ -34,7 +38,8 @@ module Integrations::Slack::SlackMessageHelper
content: Slack::Messages::Formatting.unescape(params[:event][:text] || ''),
external_source_id_slack: params[:event][:ts],
private: private_note?,
sender: sender
sender: resolved_sender,
additional_attributes: slack_sender_attrs
)
process_attachments(params[:event][:files]) if attachments_present?
@message.save!
@@ -81,9 +86,22 @@ module Integrations::Slack::SlackMessageHelper
@conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first
end
def sender
user_email = slack_client.users_info(user: params[:event][:user])[:user][:profile][:email]
conversation.account.users.from_email(user_email)
def resolve_slack_sender
return [nil, nil, nil] unless params[:event][:user]
slack_user = slack_client.users_info(user: params[:event][:user])[:user]
chatwoot_user = conversation.account.users.from_email(slack_user[:profile][:email])
return [chatwoot_user, nil, nil] if chatwoot_user
sender_name = slack_user.dig(:profile, :display_name).presence ||
slack_user[:real_name].presence ||
slack_user[:name]
sender_avatar_url = slack_user.dig(:profile, :image_192).presence
[nil, sender_name, sender_avatar_url]
rescue Slack::Web::Api::Errors::MissingScope
raise
rescue StandardError
[nil, nil, nil]
end
def private_note?
+4
View File
@@ -52,4 +52,8 @@ module Redis::RedisKeys
## Account Email Rate Limiting
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
## Transient Error Backoff
BACKOFF_RETRY_COUNT = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_count'.freeze
BACKOFF_RETRY_AFTER = 'BACKOFF:%<obj_type>s:%<obj_id>d:retry_after'.freeze
end
+24 -6
View File
@@ -1,14 +1,16 @@
class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
def initialize(url, payload, webhook_type)
def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
@url = url
@payload = payload
@webhook_type = webhook_type
@secret = secret
@delivery_id = delivery_id
end
def self.execute(url, payload, webhook_type)
new(url, payload, webhook_type).execute
def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil)
new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute
end
def execute
@@ -21,15 +23,27 @@ class Webhooks::Trigger
private
def perform_request
body = @payload.to_json
RestClient::Request.execute(
method: :post,
url: @url,
payload: @payload.to_json,
headers: { content_type: :json, accept: :json },
payload: body,
headers: request_headers(body),
timeout: webhook_timeout
)
end
def request_headers(body)
headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
if @secret.present?
ts = Time.now.to_i.to_s
headers['X-Chatwoot-Timestamp'] = ts
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
end
headers
end
def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message
@@ -72,7 +86,11 @@ class Webhooks::Trigger
def message
return if message_id.blank?
@message ||= Message.find_by(id: message_id)
if defined?(@message)
@message
else
@message = Message.find_by(id: message_id)
end
end
def message_id
@@ -59,6 +59,36 @@ describe Messages::Facebook::MessageBuilder do
expect(contact.name).to eq(default_name)
end
it 'marks echo messages as external echo messages' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
first_name: 'Jane',
last_name: 'Dae',
account_id: facebook_channel.inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
echo_message_object = {
messaging: {
sender: { id: facebook_channel.page_id },
recipient: { id: '3383290475046708' },
message: { mid: 'm_echo_1', text: 'Echo testing', is_echo: true, app_id: '263902037430900' }
}
}.to_json
echo_message = Integrations::Facebook::MessageParser.new(echo_message_object)
described_class.new(echo_message, facebook_channel.inbox, outgoing_echo: true).perform
message = facebook_channel.inbox.messages.find_by(source_id: 'm_echo_1')
expect(message).to be_present
expect(message.message_type).to eq('outgoing')
expect(message.sender).to be_nil
expect(message.status).to eq('delivered')
expect(message.content_attributes['external_echo']).to be true
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
@@ -249,6 +249,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
id: conversation.id,
display_id: conversation.display_id
},
contact_inbox: {
id: conversation.contact_inbox.id,
hmac_verified: conversation.contact_inbox.hmac_verified
},
contact: {
id: contact.id,
email: contact.email,
@@ -272,6 +276,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s,
'X-Chatwoot-Contact-Inbox-Id' => conversation.contact_inbox.id.to_s,
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
'X-Chatwoot-Contact-Id' => contact.id.to_s,
'X-Chatwoot-Contact-Email' => contact.email
})
@@ -282,6 +288,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
.with(headers: {
'X-Chatwoot-Account-Id' => account.id.to_s,
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
'X-Chatwoot-Contact-Email' => contact.email
})
end
@@ -296,6 +303,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
'Content-Type' => 'application/json',
'X-Chatwoot-Account-Id' => account.id.to_s,
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
'X-Chatwoot-Contact-Email' => contact.email
}
)
@@ -316,6 +324,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
.with(headers: {
'Authorization' => 'Bearer test_token',
'X-Chatwoot-Account-Id' => account.id.to_s,
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s,
'X-Chatwoot-Contact-Id' => contact.id.to_s
})
.to_return(status: 200, body: '{"success": true}')
@@ -336,13 +345,18 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
conversation: {
id: conversation.id,
display_id: conversation.display_id
},
contact_inbox: {
id: conversation.contact_inbox.id,
hmac_verified: conversation.contact_inbox.hmac_verified
}
})
stub_request(:get, 'https://example.com/api/data')
.with(headers: {
'X-Chatwoot-Account-Id' => account.id.to_s,
'X-Chatwoot-Conversation-Id' => conversation.id.to_s
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s
})
.to_return(status: 200, body: '{"success": true}')
@@ -351,6 +365,32 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
end
it 'defaults contact inbox verified header to false when contact inbox is missing' do
tool_context_without_contact_inbox = Struct.new(:state).new({
account_id: account.id,
assistant_id: assistant.id,
conversation: {
id: conversation.id,
display_id: conversation.display_id
},
contact: {
id: contact.id,
email: contact.email
}
})
stub_request(:get, 'https://example.com/api/data')
.with(headers: {
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
})
.to_return(status: 200, body: '{"success": true}')
tool.perform(tool_context_without_contact_inbox)
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
end
it 'includes contact phone when present' do
contact.update!(phone_number: '+1234567890')
tool_context_with_state.state[:contact][:phone_number] = '+1234567890'
@@ -366,6 +406,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
.with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' })
end
it 'includes unverified contact inbox status explicitly as false' do
conversation.contact_inbox.update!(hmac_verified: false)
tool_context_with_state.state[:contact_inbox][:hmac_verified] = false
stub_request(:get, 'https://example.com/api/data')
.with(headers: {
'X-Chatwoot-Contact-Inbox-Verified' => 'false'
})
.to_return(status: 200, body: '{"success": true}')
tool.perform(tool_context_with_state)
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
.with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' })
end
end
end
end
@@ -341,6 +341,10 @@ RSpec.describe Captain::CustomTool, type: :model do
id: conversation.id,
display_id: conversation.display_id
},
contact_inbox: {
id: conversation.contact_inbox.id,
hmac_verified: conversation.contact_inbox.hmac_verified
},
contact: {
id: contact.id,
email: contact.email,
@@ -376,6 +380,13 @@ RSpec.describe Captain::CustomTool, type: :model do
expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email)
end
it 'includes contact inbox verification metadata when present' do
headers = tool.build_metadata_headers(state)
expect(headers['X-Chatwoot-Contact-Inbox-Id']).to eq(conversation.contact_inbox.id.to_s)
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq(conversation.contact_inbox.hmac_verified.to_s)
end
it 'handles missing conversation gracefully' do
state[:conversation] = nil
@@ -396,11 +407,21 @@ RSpec.describe Captain::CustomTool, type: :model do
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
end
it 'handles missing contact inbox gracefully' do
state[:contact_inbox] = nil
headers = tool.build_metadata_headers(state)
expect(headers['X-Chatwoot-Contact-Inbox-Id']).to be_nil
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
end
it 'handles empty state' do
headers = tool.build_metadata_headers({})
expect(headers).to be_a(Hash)
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
end
it 'omits contact email header when email is blank' do
@@ -418,6 +439,22 @@ RSpec.describe Captain::CustomTool, type: :model do
expect(headers).not_to have_key('X-Chatwoot-Contact-Phone')
end
it 'includes contact inbox verified header when false' do
state[:contact_inbox][:hmac_verified] = false
headers = tool.build_metadata_headers(state)
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
end
it 'defaults contact inbox verified header to false when value is nil' do
state[:contact_inbox][:hmac_verified] = nil
headers = tool.build_metadata_headers(state)
expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false')
end
end
describe '#to_tool_metadata' do
@@ -384,6 +384,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(state[:channel_type]).to eq(inbox.channel_type)
end
it 'includes contact inbox attributes when conversation is present' do
state = service.send(:build_state)
expect(state[:contact_inbox]).to include(
id: conversation.contact_inbox.id,
hmac_verified: conversation.contact_inbox.hmac_verified
)
end
it 'includes contact attributes when contact is present' do
state = service.send(:build_state)
+1 -1
View File
@@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do
end
it 'executes perform' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job }
end
end
@@ -38,6 +38,14 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
end
context 'when channel is in backoff' do
it 'does not fetch emails' do
allow(imap_email_channel).to receive(:in_backoff?).and_return(true)
expect(Imap::FetchEmailService).not_to receive(:new)
described_class.perform_now(imap_email_channel)
end
end
context 'when the channel is regular imap' do
it 'calls the imap fetch service' do
fetch_service = double
@@ -56,6 +64,17 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
described_class.perform_now(imap_email_channel, 4)
expect(fetch_service).to have_received(:perform)
end
it 'clears backoff after successful fetch' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
allow(imap_email_channel).to receive(:clear_backoff!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:clear_backoff!)
end
end
context 'when the channel is Microsoft' do
@@ -69,6 +88,37 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
end
context 'when authentication error is raised' do
it 'calls authorization_error! on the channel' do
allow(Imap::FetchEmailService).to receive(:new).and_raise(Imap::AuthenticationError)
allow(imap_email_channel).to receive(:authorization_error!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:authorization_error!)
end
end
context 'when a transient IMAP error is raised' do
it 'calls apply_backoff! on the channel' do
allow(Imap::FetchEmailService).to receive(:new).and_raise(EOFError)
allow(imap_email_channel).to receive(:apply_backoff!)
described_class.perform_now(imap_email_channel)
expect(imap_email_channel).to have_received(:apply_backoff!)
end
end
context 'when lock acquisition fails' do
it 'does not raise an error' do
lock_manager = instance_double(Redis::LockManager, lock: false)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
expect { described_class.perform_now(imap_email_channel) }.not_to raise_error
end
end
context 'when IMAP OAuth errors out' do
it 'marks the connection as requiring authorization' do
error_response = double
+2 -2
View File
@@ -16,7 +16,7 @@ RSpec.describe WebhookJob do
end
it 'executes perform with default webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job }
end
@@ -24,7 +24,7 @@ RSpec.describe WebhookJob do
let(:webhook_type) { :api_inbox_webhook }
it 'executes perform with inbox webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil)
perform_enqueued_jobs { job }
end
end
@@ -69,7 +69,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
builder = described_class.new(message_params)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
2.times.each { builder.perform }
expect(conversation.messages.count).to eql(messages_count + 1)
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
@@ -79,7 +79,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
builder = described_class.new(message_params)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
builder.perform
expect(conversation.messages.count).to eql(messages_count + 1)
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
@@ -89,7 +89,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
builder = described_class.new(private_message_params)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
builder.perform
expect(conversation.messages.count).to eql(messages_count + 1)
expect(conversation.messages.last.content).to eql('pRivate: A private note message')
@@ -130,7 +130,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
messages_count = conversation.messages.count
message_with_attachments[:event][:files] = nil
builder = described_class.new(message_with_attachments)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
builder.perform
expect(conversation.messages.count).to eql(messages_count)
end
@@ -139,7 +139,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(hook).not_to be_nil
messages_count = conversation.messages.count
builder = described_class.new(message_with_attachments)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
builder.perform
expect(conversation.messages.count).to eql(messages_count + 1)
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
@@ -152,7 +152,7 @@ describe Integrations::Slack::IncomingMessageBuilder do
message_with_attachments[:event][:text] = 'Attached File!'
builder = described_class.new(message_with_attachments)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
builder.perform
expect(conversation.messages.count).to eql(messages_count)
@@ -165,13 +165,113 @@ describe Integrations::Slack::IncomingMessageBuilder do
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
builder = described_class.new(video_attachment_params)
allow(builder).to receive(:sender).and_return(nil)
allow(builder).to receive(:resolve_slack_sender).and_return([nil, nil, nil])
expect { builder.perform }.not_to raise_error
expect(conversation.messages.last.attachments).to be_any
end
end
context 'when resolving slack sender' do
let(:builder) { described_class.new(message_params) }
before do
allow(builder).to receive(:slack_client).and_return(slack_client)
end
context 'when slack user email matches a chatwoot agent' do
before do
create(:user, account: conversation.account, email: 'agent@example.com')
slack_response = {
user: {
profile: { email: 'agent@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' },
real_name: 'Muhsin K',
name: 'muhsink'
}
}
allow(slack_client).to receive(:users_info)
.with(user: message_params[:event][:user])
.and_return(slack_response)
end
it 'sets the matched agent as message sender' do
builder.perform
expect(conversation.messages.last.sender).to eq(conversation.account.users.from_email('agent@example.com'))
end
it 'does not store sender_name in additional_attributes' do
builder.perform
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name')
end
end
context 'when slack user email does not match any chatwoot agent' do
before do
slack_response = {
user: {
profile: { email: 'unknown@example.com', display_name: 'Muhsin K', image_192: 'https://avatars.slack-edge.com/avatar.png' },
real_name: 'Muhsin K',
name: 'muhsink'
}
}
allow(slack_client).to receive(:users_info)
.with(user: message_params[:event][:user])
.and_return(slack_response)
end
it 'saves sender_name from slack display_name in additional_attributes' do
builder.perform
expect(conversation.messages.last.sender).to be_nil
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K')
end
it 'saves sender_avatar_url from slack profile image in additional_attributes' do
builder.perform
expect(conversation.messages.last.additional_attributes['sender_avatar_url'])
.to eq('https://avatars.slack-edge.com/avatar.png')
end
it 'falls back to real_name when display_name is blank' do
allow(slack_client).to receive(:users_info).and_return({
user: {
profile: { email: 'unknown@example.com', display_name: '',
image_192: nil }, real_name: 'Muhsin K', name: 'muhsink'
}
})
builder.perform
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('Muhsin K')
end
it 'falls back to slack username when display_name and real_name are both blank' do
allow(slack_client).to receive(:users_info).and_return({
user: {
profile: { email: 'unknown@example.com', display_name: '',
image_192: nil }, real_name: '', name: 'muhsink'
}
})
builder.perform
expect(conversation.messages.last.additional_attributes['sender_name']).to eq('muhsink')
end
end
context 'when the slack API call raises an error' do
before do
allow(slack_client).to receive(:users_info).and_raise(StandardError, 'API error')
end
it 'creates the message with nil sender' do
expect { builder.perform }.not_to raise_error
expect(conversation.messages.last.sender).to be_nil
end
it 'does not store sender info in additional_attributes' do
builder.perform
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_name')
expect(conversation.messages.last.additional_attributes).not_to have_key('sender_avatar_url')
end
end
end
context 'when link shared' do
let(:link_shared) do
{
+65
View File
@@ -168,6 +168,71 @@ describe Webhooks::Trigger do
end
end
describe 'request headers' do
let(:payload) { { event: 'message_created' } }
let(:body) { payload.to_json }
context 'without secret or delivery_id' do
it 'sends only content-type and accept headers' do
expect(RestClient::Request).to receive(:execute).with(
hash_including(headers: { content_type: :json, accept: :json })
)
trigger.execute(url, payload, webhook_type)
end
end
context 'with delivery_id' do
it 'adds X-Chatwoot-Delivery header' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid')
expect(args[:headers]).not_to have_key('X-Chatwoot-Signature')
expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp')
end
trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid')
end
end
context 'with secret' do
let(:secret) { 'test-secret' }
it 'adds X-Chatwoot-Timestamp header' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'adds X-Chatwoot-Signature header with correct HMAC' do
expect(RestClient::Request).to receive(:execute) do |args|
ts = args[:headers]['X-Chatwoot-Timestamp']
expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}"
expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
it 'signs timestamp.body not just body' do
expect(RestClient::Request).to receive(:execute) do |args|
args[:headers]['X-Chatwoot-Timestamp']
wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig)
end
trigger.execute(url, payload, webhook_type, secret: secret)
end
end
context 'with both secret and delivery_id' do
it 'includes all three security headers' do
expect(RestClient::Request).to receive(:execute) do |args|
expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123')
expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present
expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=')
end
trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123')
end
end
end
it 'does not update message status if webhook fails for other events' do
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }
+46 -15
View File
@@ -28,7 +28,10 @@ describe WebhookListener do
context 'when webhook is configured and event is subscribed' do
it 'triggers the webhook event' do
webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.message_created(message_created_event)
end
end
@@ -54,8 +57,10 @@ describe WebhookListener do
conversation: api_conversation
)
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook).once
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
).once
listener.message_created(api_event)
end
@@ -90,7 +95,10 @@ describe WebhookListener do
context 'when webhook is configured' do
it 'triggers webhook' do
webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_created(conversation_created_event)
end
end
@@ -101,9 +109,11 @@ describe WebhookListener do
api_inbox = channel_api.inbox
api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user)
api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation)
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url,
api_conversation.webhook_data.merge(event: 'conversation_created'),
:api_inbox_webhook).once
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url,
api_conversation.webhook_data.merge(event: 'conversation_created'),
:api_inbox_webhook, delivery_id: instance_of(String)
).once
listener.conversation_created(api_event)
end
@@ -156,7 +166,9 @@ describe WebhookListener do
}
}
]
)
),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_updated(conversation_updated_event)
@@ -177,7 +189,10 @@ describe WebhookListener do
context 'when webhook is configured' do
it 'triggers webhook' do
webhook = create(:webhook, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.contact_created(contact_event)
end
end
@@ -213,7 +228,9 @@ describe WebhookListener do
contact.webhook_data.merge(
event: 'contact_updated',
changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }]
)
),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.contact_updated(contact_updated_event)
end
@@ -235,7 +252,10 @@ describe WebhookListener do
it 'triggers webhook' do
inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.inbox_created(inbox_created_event)
end
end
@@ -272,7 +292,9 @@ describe WebhookListener do
expect(WebhookJob).to receive(:perform_later).with(
webhook.url,
inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data)
inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data),
:account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.inbox_updated(inbox_updated_event)
@@ -302,7 +324,10 @@ describe WebhookListener do
is_private: false
}
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, payload, :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_typing_on(typing_event)
end
end
@@ -321,7 +346,10 @@ describe WebhookListener do
is_private: false
}
expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, payload, :api_inbox_webhook,
delivery_id: instance_of(String)
).once
listener.conversation_typing_on(api_event)
end
end
@@ -349,7 +377,10 @@ describe WebhookListener do
is_private: false
}
expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, payload, :account_webhook,
secret: webhook.secret, delivery_id: instance_of(String)
).once
listener.conversation_typing_off(typing_event)
end
end
+2
View File
@@ -2,12 +2,14 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
require Rails.root.join 'spec/models/concerns/backoffable_shared.rb'
RSpec.describe Channel::Email do
let(:channel) { create(:channel_email) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
it_behaves_like 'backoffable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for email' do
@@ -0,0 +1,43 @@
require 'rails_helper'
shared_examples_for 'backoffable' do
let(:obj) { FactoryBot.create(described_class.to_s.underscore.tr('/', '_').to_sym) }
before do
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_MINUTES', 5).and_return(2)
allow(GlobalConfigService).to receive(:load).with('BACKOFF_MAX_INTERVAL_COUNT', 10).and_return(3)
# max_interval=2, max_retries=(2-1)+3=4; exhausts on 5th apply_backoff!
end
it 'starts with no backoff' do
expect(obj.in_backoff?).to be false
expect(obj.backoff_retry_count).to eq 0
end
it 'ramps backoff on each failure' do
obj.apply_backoff!
expect(obj.backoff_retry_count).to eq 1
expect(obj.in_backoff?).to be true
end
it 'caps wait time at max interval' do
4.times { obj.apply_backoff! }
expect(obj.backoff_retry_count).to eq 4
expect(obj.in_backoff?).to be true
end
it 'exhausts backoff and calls prompt_reauthorization! after max retries' do
allow(obj).to receive(:prompt_reauthorization!)
5.times { obj.apply_backoff! }
expect(obj).to have_received(:prompt_reauthorization!)
expect(obj.backoff_retry_count).to eq 0
expect(obj.in_backoff?).to be false
end
it 'clear_backoff! resets retry count and backoff window' do
obj.apply_backoff!
obj.clear_backoff!
expect(obj.in_backoff?).to be false
expect(obj.backoff_retry_count).to eq 0
end
end
+16
View File
@@ -8,4 +8,20 @@ RSpec.describe Webhook do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'secret token' do
let!(:account) { create(:account) }
it 'auto-generates a secret on create' do
webhook = create(:webhook, account: account)
expect(webhook.secret).to be_present
end
it 'does not regenerate the secret on update' do
webhook = create(:webhook, account: account)
original_secret = webhook.secret
webhook.update!(url: "#{webhook.url}?updated=1")
expect(webhook.reload.secret).to eq(original_secret)
end
end
end