diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 2c55922f6..1f59deadb 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -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 diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 7821bee49..fa437327d 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -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], diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index 66234984c..3de9d2d04 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -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; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index bc0001238..5f76041dc 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -380,7 +380,11 @@ export default { @click="$emit('selectContentTemplate')" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue index d699923f5..fa1563ed8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBasicFilter.vue @@ -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', diff --git a/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue b/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue index 5c1c79d23..bd0f645cf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue +++ b/app/javascript/dashboard/components/widgets/conversation/EmailTranscriptModal.vue @@ -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; } diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index e55f13b7c..23b4d89a8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -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 ); diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js index eb42a270f..21303efcb 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -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, diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 353bed96e..858c0ecbc 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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', diff --git a/app/javascript/dashboard/helper/featureHelper.js b/app/javascript/dashboard/helper/featureHelper.js index ae805ccf1..c90ec15db 100644 --- a/app/javascript/dashboard/helper/featureHelper.js +++ b/app/javascript/dashboard/helper/featureHelper.js @@ -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', diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 92e67635b..0e8e87a04 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -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": { diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 951a46993..835a7e512 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -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", diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 8ee990006..c8e488959 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -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", diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue index 15db70c40..7213c735a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue @@ -58,6 +58,7 @@ export default { }, }, mounted() { + this.$store.dispatch('integrations/get', 'webhook'); this.$store.dispatch('webhooks/get'); }, methods: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue index 491a3cd87..76c4e895f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue @@ -1,60 +1,98 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 3f4b31299..3bcef1ca2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -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" /> + diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 0c4c084e0..c6a197d85 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -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 ( diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js index ebbdcbe64..af6b16023 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers.js @@ -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) { diff --git a/app/javascript/dashboard/store/modules/webhooks.js b/app/javascript/dashboard/store/modules/webhooks.js index eb096468e..774c173d3 100644 --- a/app/javascript/dashboard/store/modules/webhooks.js +++ b/app/javascript/dashboard/store/modules/webhooks.js @@ -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; diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue index e8d245ddb..a13f7bf65 100755 --- a/app/javascript/widget/components/AgentMessage.vue +++ b/app/javascript/widget/components/AgentMessage.vue @@ -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 ( diff --git a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb index ea2705955..ea7f7664f 100644 --- a/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_imap_email_inboxes_job.rb @@ -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) diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index ec5717f3b..74368c830 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -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) diff --git a/app/jobs/webhook_job.rb b/app/jobs/webhook_job.rb index 57d3739b7..54eac45e3 100644 --- a/app/jobs/webhook_job.rb +++ b/app/jobs/webhook_job.rb @@ -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 diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index 82a9fc711..762eaa6ee 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -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) diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index b1124dd75..1091b17d8 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -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 diff --git a/app/models/concerns/backoffable.rb b/app/models/concerns/backoffable.rb new file mode 100644 index 000000000..60b6ac98c --- /dev/null +++ b/app/models/concerns/backoffable.rb @@ -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 diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index 7a09f6436..b25f5525e 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -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 diff --git a/app/models/concerns/sort_handler.rb b/app/models/concerns/sort_handler.rb index 00eb73717..065fa7fea 100644 --- a/app/models/concerns/sort_handler.rb +++ b/app/models/concerns/sort_handler.rb @@ -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 diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 1d61c1614..6b36c4bbd 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -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 diff --git a/app/services/imap/authentication_error.rb b/app/services/imap/authentication_error.rb new file mode 100644 index 000000000..5698f5b3b --- /dev/null +++ b/app/services/imap/authentication_error.rb @@ -0,0 +1 @@ +class Imap::AuthenticationError < StandardError; end diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index 09332092c..49e06491b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -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 diff --git a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder index 5406cf183..7b1943c5d 100644 --- a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder +++ b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder @@ -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 diff --git a/config/features.yml b/config/features.yml index d8378d61c..65b3c6194 100644 --- a/config/features.yml +++ b/config/features.yml @@ -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 diff --git a/config/installation_config.yml b/config/installation_config.yml index 34cb736bf..0d22de6fc 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -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' diff --git a/db/migrate/20260218075101_add_secret_to_webhooks.rb b/db/migrate/20260218075101_add_secret_to_webhooks.rb new file mode 100644 index 000000000..ff6c40c4c --- /dev/null +++ b/db/migrate/20260218075101_add_secret_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddSecretToWebhooks < ActiveRecord::Migration[7.1] + def change + add_column :webhooks, :secret, :string + end +end diff --git a/db/migrate/20260226084618_backfill_webhook_secrets.rb b/db/migrate/20260226084618_backfill_webhook_secrets.rb new file mode 100644 index 000000000..aec6cfde7 --- /dev/null +++ b/db/migrate/20260226084618_backfill_webhook_secrets.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index fd4d18cb1..8a450e734 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_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 diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index 51ec1be3e..f40ac4a65 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -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? diff --git a/enterprise/app/models/enterprise/audit/webhook.rb b/enterprise/app/models/enterprise/audit/webhook.rb index 34e0bcc1b..303141ce2 100644 --- a/enterprise/app/models/enterprise/audit/webhook.rb +++ b/enterprise/app/models/enterprise/audit/webhook.rb @@ -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 diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 8238ca4d8..bdf35e98e 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -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) diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 52a28844f..d3c5b15db 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -11,7 +11,6 @@ class Enterprise::Billing::HandleStripeEventService help_center campaigns team_management - channel_twitter channel_facebook channel_email channel_instagram diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml index 64275503d..0cb89df01 100644 --- a/enterprise/config/premium_features.yml +++ b/enterprise/config/premium_features.yml @@ -1,7 +1,6 @@ # List of the premium features in EE edition - disable_branding - audit_logs -- response_bot - sla - custom_roles - captain_integration diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 1ba3ba7a7..aa94ae1d4 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -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 -%} diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 10eeb6fd7..6d0f11821 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -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 -%} diff --git a/lib/exception_list.rb b/lib/exception_list.rb index 2fee0a170..dd468f07b 100644 --- a/lib/exception_list.rb +++ b/lib/exception_list.rb @@ -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 diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb index 0ee328fb3..3af57a4c3 100644 --- a/lib/integrations/slack/slack_message_helper.rb +++ b/lib/integrations/slack/slack_message_helper.rb @@ -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? diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index 8c9361ab5..fa9eeb31e 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -52,4 +52,8 @@ module Redis::RedisKeys ## Account Email Rate Limiting ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%d::%s'.freeze + + ## Transient Error Backoff + BACKOFF_RETRY_COUNT = 'BACKOFF:%s:%d:retry_count'.freeze + BACKOFF_RETRY_AFTER = 'BACKOFF:%s:%d:retry_after'.freeze end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index ef3410b78..456e186ca 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -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 diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index 4b94c9be4..525ad7736 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index 967a10574..e05308a7d 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -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 diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index 0ead8fb1f..60b66778f 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -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 diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 0d22b8266..2ac3c6589 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -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) diff --git a/spec/jobs/agent_bots/webhook_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb index a8117d84e..346d85e83 100644 --- a/spec/jobs/agent_bots/webhook_job_spec.rb +++ b/spec/jobs/agent_bots/webhook_job_spec.rb @@ -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 diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index da4b95b15..1abd7450b 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -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 diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb index 81802a3c0..c74c1d8a8 100644 --- a/spec/jobs/webhook_job_spec.rb +++ b/spec/jobs/webhook_job_spec.rb @@ -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 diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb index 2ce206489..65234767e 100644 --- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -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 { diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 79cf92150..1e047b557 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -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 } diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index 5062b11bc..51dae239b 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -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 diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index 939e8e10a..bdb9f859b 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -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 diff --git a/spec/models/concerns/backoffable_shared.rb b/spec/models/concerns/backoffable_shared.rb new file mode 100644 index 000000000..385f93be7 --- /dev/null +++ b/spec/models/concerns/backoffable_shared.rb @@ -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 diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 81e6d9551..b8570de59 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -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