Merge branch 'develop' into chore/update-rails
This commit is contained in:
@@ -18,12 +18,25 @@ module ReportingEventHelper
|
||||
end
|
||||
|
||||
def last_non_human_activity(conversation)
|
||||
# check if a handoff event already exists
|
||||
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
|
||||
# Try to get either a handoff or reopened event first
|
||||
# These will always take precedence over any other activity
|
||||
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
|
||||
# So we pick the latest event
|
||||
event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: %w[conversation_bot_handoff conversation_opened]
|
||||
).order(event_end_time: :desc).first
|
||||
|
||||
# if a handoff exists, last non human activity is when the handoff ended,
|
||||
# otherwise it's when the conversation was created
|
||||
handoff_event&.event_end_time || conversation.created_at
|
||||
return event.event_end_time if event&.event_end_time
|
||||
|
||||
# Fallback to bot resolved event
|
||||
# Because this will be closest to the most accurate activity instead of conversation.created_at
|
||||
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
|
||||
|
||||
return bot_event.event_end_time if bot_event&.event_end_time
|
||||
|
||||
# If no events found, return conversation creation time
|
||||
conversation.created_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (props.isModal) return '';
|
||||
if (viewInModal.value) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
@@ -179,14 +188,18 @@ const toggle = () => {
|
||||
|
||||
watch(
|
||||
activeContact,
|
||||
() => {
|
||||
if (activeContact.value && props.contactId) {
|
||||
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||
(currentContact, previousContact) => {
|
||||
if (currentContact && props.contactId) {
|
||||
// Reset on contact change
|
||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
||||
|
||||
// First process the contactable inboxes to get the right structure
|
||||
const processedInboxes = processContactableInboxes(contactInboxes);
|
||||
const processedInboxes = processContactableInboxes(
|
||||
currentContact.contactInboxes || []
|
||||
);
|
||||
// Then Merge processedInboxes with the inboxes list
|
||||
selectedContact.value = {
|
||||
...activeContact.value,
|
||||
...currentContact,
|
||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||
};
|
||||
}
|
||||
@@ -202,7 +215,7 @@ const handleClickOutside = () => {
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!props.isModal) return;
|
||||
if (!viewInModal.value) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
@@ -231,7 +244,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-40': showComposeNewConversation,
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
@@ -243,12 +256,12 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
isModal,
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<ComposeNewConversationForm
|
||||
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
||||
>
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
|
||||
@@ -509,10 +509,8 @@ const menuItems = computed(() => {
|
||||
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
||||
:class="[
|
||||
{
|
||||
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
|
||||
isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
|
||||
!isMobileSidebarOpen,
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -196,6 +196,7 @@ describe('useAutomation', () => {
|
||||
automationTypes.conversation_created = { conditions: [] };
|
||||
automationTypes.conversation_updated = { conditions: [] };
|
||||
automationTypes.conversation_opened = { conditions: [] };
|
||||
automationTypes.conversation_resolved = { conditions: [] };
|
||||
|
||||
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
||||
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
||||
|
||||
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
|
||||
export const DEFAULT_CONVERSATION_CONDITION = [
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||
import {
|
||||
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_CONVERSATION_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
} from 'dashboard/constants/automation';
|
||||
@@ -169,8 +169,11 @@ export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
}
|
||||
if (eventName === 'conversation_opened') {
|
||||
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||
if (
|
||||
eventName === 'conversation_opened' ||
|
||||
eventName === 'conversation_resolved'
|
||||
) {
|
||||
return DEFAULT_CONVERSATION_CONDITION;
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
};
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
"CONVERSATION_CREATED": "Conversation Created",
|
||||
"CONVERSATION_UPDATED": "Conversation Updated",
|
||||
"MESSAGE_CREATED": "Message Created",
|
||||
"CONVERSATION_RESOLVED": "Conversation Resolved",
|
||||
"CONVERSATION_OPENED": "Conversation Opened"
|
||||
},
|
||||
"ACTIONS": {
|
||||
|
||||
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
conversation_resolved: {
|
||||
conditions: [
|
||||
{
|
||||
key: 'browser_language',
|
||||
name: 'BROWSER_LANGUAGE',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
name: 'EMAIL',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'mail_subject',
|
||||
name: 'MAIL_SUBJECT',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'country_code',
|
||||
name: 'COUNTRY_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'referer',
|
||||
name: 'REFERER_LINK',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_2,
|
||||
},
|
||||
{
|
||||
key: 'assignee_id',
|
||||
name: 'ASSIGNEE_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
},
|
||||
{
|
||||
key: 'phone_number',
|
||||
name: 'PHONE_NUMBER',
|
||||
inputType: 'plain_text',
|
||||
filterOperators: OPERATOR_TYPES_6,
|
||||
},
|
||||
{
|
||||
key: 'team_id',
|
||||
name: 'TEAM_NAME',
|
||||
inputType: 'search_select',
|
||||
filterOperators: OPERATOR_TYPES_3,
|
||||
},
|
||||
{
|
||||
key: 'inbox_id',
|
||||
name: 'INBOX',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'conversation_language',
|
||||
name: 'CONVERSATION_LANGUAGE',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
name: 'PRIORITY',
|
||||
inputType: 'multi_select',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
key: 'assign_agent',
|
||||
name: 'ASSIGN_AGENT',
|
||||
},
|
||||
{
|
||||
key: 'assign_team',
|
||||
name: 'ASSIGN_TEAM',
|
||||
},
|
||||
{
|
||||
key: 'send_email_to_team',
|
||||
name: 'SEND_EMAIL_TO_TEAM',
|
||||
},
|
||||
{
|
||||
key: 'send_message',
|
||||
name: 'SEND_MESSAGE',
|
||||
},
|
||||
{
|
||||
key: 'send_email_transcript',
|
||||
name: 'SEND_EMAIL_TRANSCRIPT',
|
||||
},
|
||||
{
|
||||
key: 'send_webhook_event',
|
||||
name: 'SEND_WEBHOOK_EVENT',
|
||||
},
|
||||
{
|
||||
key: 'send_attachment',
|
||||
name: 'SEND_ATTACHMENT',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTOMATION_RULE_EVENTS = [
|
||||
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
|
||||
key: 'conversation_updated',
|
||||
value: 'CONVERSATION_UPDATED',
|
||||
},
|
||||
{
|
||||
key: 'conversation_resolved',
|
||||
value: 'CONVERSATION_RESOLVED',
|
||||
},
|
||||
{
|
||||
key: 'message_created',
|
||||
value: 'MESSAGE_CREATED',
|
||||
|
||||
@@ -1,53 +1,18 @@
|
||||
class AutomationRuleListener < BaseListener
|
||||
def conversation_updated(event)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_updated', account)
|
||||
|
||||
rules = current_account_rules('conversation_updated', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
process_conversation_event(event, 'conversation_updated')
|
||||
end
|
||||
|
||||
def conversation_created(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_created', account)
|
||||
|
||||
rules = current_account_rules('conversation_created', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
process_conversation_event(event, 'conversation_created')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
|
||||
process_conversation_event(event, 'conversation_opened')
|
||||
end
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?('conversation_opened', account)
|
||||
|
||||
rules = current_account_rules('conversation_opened', account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
def conversation_resolved(event)
|
||||
process_conversation_event(event, 'conversation_resolved')
|
||||
end
|
||||
|
||||
def message_created(event)
|
||||
@@ -69,6 +34,28 @@ class AutomationRuleListener < BaseListener
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_conversation_event(event, event_name)
|
||||
return if performed_by_automation?(event)
|
||||
|
||||
auto_reply_skip_events = %w[conversation_created conversation_opened]
|
||||
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
|
||||
|
||||
conversation = event.data[:conversation]
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?(event_name, account)
|
||||
|
||||
rules = current_account_rules(event_name, account)
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
def rule_present?(event_name, account)
|
||||
return if account.blank?
|
||||
|
||||
|
||||
@@ -90,8 +90,47 @@ class ReportingEventListener < BaseListener
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
|
||||
# Find the most recent resolved event for this conversation
|
||||
last_resolved_event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: 'conversation_resolved'
|
||||
).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
business_hours_value = 0
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
value_in_business_hours: business_hours_value,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: conversation.updated_at
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
|
||||
@@ -62,7 +62,12 @@ class Attachment < ApplicationRecord
|
||||
def thumb_url
|
||||
return '' unless file.attached? && image?
|
||||
|
||||
url_for(file.representation(resize_to_fill: [250, nil]))
|
||||
begin
|
||||
url_for(file.representation(resize_to_fill: [250, nil]))
|
||||
rescue ActiveStorage::UnrepresentableError => e
|
||||
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
|
||||
''
|
||||
end
|
||||
end
|
||||
|
||||
def with_attached_file?
|
||||
|
||||
@@ -32,7 +32,6 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validates :phone_number, presence: true, uniqueness: true
|
||||
validate :validate_provider_config
|
||||
|
||||
before_save :setup_webhooks
|
||||
after_create :sync_templates
|
||||
before_destroy :teardown_webhooks
|
||||
|
||||
@@ -60,6 +59,13 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
delegate :media_url, to: :provider_service
|
||||
delegate :api_headers, to: :provider_service
|
||||
|
||||
def setup_webhooks
|
||||
perform_webhook_setup
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
|
||||
prompt_reauthorization!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_webhook_verify_token
|
||||
@@ -70,34 +76,6 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
def setup_webhooks
|
||||
return unless should_setup_webhooks?
|
||||
|
||||
perform_webhook_setup
|
||||
rescue StandardError => e
|
||||
handle_webhook_setup_error(e)
|
||||
end
|
||||
|
||||
def provider_config_changed?
|
||||
will_save_change_to_provider_config?
|
||||
end
|
||||
|
||||
def should_setup_webhooks?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
provider_config['business_account_id'].present? && provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
business_account_id = provider_config['business_account_id']
|
||||
api_key = provider_config['api_key']
|
||||
@@ -105,12 +83,6 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
|
||||
end
|
||||
|
||||
def handle_webhook_setup_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel creation from failing
|
||||
# Webhooks can be retried later
|
||||
end
|
||||
|
||||
def teardown_webhooks
|
||||
Whatsapp::WebhookTeardownService.new(self).perform
|
||||
end
|
||||
|
||||
@@ -68,14 +68,23 @@ class Notification::PushNotificationService
|
||||
|
||||
WebPush.payload_send(**browser_push_payload(subscription))
|
||||
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
|
||||
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
|
||||
Rails.logger.info "WebPush subscription expired: #{e.message}"
|
||||
subscription.destroy!
|
||||
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "WebPush operation error: #{e.message}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
|
||||
true
|
||||
handle_browser_push_error(e, subscription)
|
||||
end
|
||||
|
||||
def handle_browser_push_error(error, subscription)
|
||||
case error
|
||||
when WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized
|
||||
Rails.logger.info "WebPush subscription expired: #{error.message}"
|
||||
subscription.destroy!
|
||||
when WebPush::TooManyRequests
|
||||
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
|
||||
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
|
||||
Rails.logger.error "WebPush operation error: #{error.message}"
|
||||
else
|
||||
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def send_fcm_push(subscription)
|
||||
|
||||
@@ -33,15 +33,14 @@ class Whatsapp::ChannelCreationService
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
channel = build_channel
|
||||
create_inbox(channel)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel
|
||||
Channel::Whatsapp.create!(
|
||||
def build_channel
|
||||
Channel::Whatsapp.build(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
|
||||
@@ -11,16 +11,34 @@ class Whatsapp::EmbeddedSignupService
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = Whatsapp::TokenExchangeService.new(@code).perform
|
||||
access_token = exchange_code_for_token
|
||||
phone_info = fetch_phone_info(access_token)
|
||||
validate_token_access(access_token)
|
||||
|
||||
# Fetch phone information
|
||||
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
channel = create_or_reauthorize_channel(access_token, phone_info)
|
||||
channel.setup_webhooks
|
||||
channel
|
||||
|
||||
# Validate token has access to the WABA
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def exchange_code_for_token
|
||||
Whatsapp::TokenExchangeService.new(@code).perform
|
||||
end
|
||||
|
||||
def fetch_phone_info(access_token)
|
||||
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
end
|
||||
|
||||
def validate_token_access(access_token)
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
end
|
||||
|
||||
# Reauthorization flow if inbox_id is present
|
||||
def create_or_reauthorize_channel(access_token, phone_info)
|
||||
if @inbox_id.present?
|
||||
Whatsapp::ReauthorizationService.new(
|
||||
account: @account,
|
||||
@@ -29,17 +47,11 @@ class Whatsapp::EmbeddedSignupService
|
||||
business_id: @business_id
|
||||
).perform(access_token, phone_info)
|
||||
else
|
||||
# Create channel for new authorization
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
|
||||
@@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService
|
||||
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
|
||||
body_params = convert_hash_to_body_params(legacy_params)
|
||||
enhanced['body'] = body_params unless body_params.empty?
|
||||
when NilClass
|
||||
# Templates without parameters (nil processed_params)
|
||||
# Return empty enhanced structure
|
||||
else
|
||||
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
|
||||
end
|
||||
|
||||
@@ -63,6 +63,25 @@ FactoryBot.define do
|
||||
],
|
||||
'sub_category' => 'CUSTOM',
|
||||
'parameter_format' => 'NAMED'
|
||||
},
|
||||
{
|
||||
'name' => 'test_no_params_template',
|
||||
'status' => 'APPROVED',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||
'id' => '9876543210987654',
|
||||
'length' => 1,
|
||||
'parameter_format' => 'POSITIONAL',
|
||||
'previous_category' => 'MARKETING',
|
||||
'sub_category' => 'CUSTOM',
|
||||
'components' => [
|
||||
{
|
||||
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂',
|
||||
'type' => 'BODY'
|
||||
}
|
||||
],
|
||||
'rejected_reason' => 'NONE'
|
||||
}]
|
||||
end
|
||||
message_templates_last_updated { Time.now.utc }
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReportingEventHelper, type: :helper do
|
||||
describe '#last_non_human_activity' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
context 'when conversation has no events' do
|
||||
it 'returns conversation created_at' do
|
||||
expect(helper.last_non_human_activity(conversation)).to eq(conversation.created_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has bot handoff event' do
|
||||
let!(:handoff_event) do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_bot_handoff',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_end_time: 2.hours.ago)
|
||||
end
|
||||
|
||||
it 'returns handoff event end time' do
|
||||
expect(helper.last_non_human_activity(conversation).to_i).to eq(handoff_event.event_end_time.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has bot resolved event' do
|
||||
let!(:bot_resolved_event) do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_bot_resolved',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_end_time: 3.hours.ago)
|
||||
end
|
||||
|
||||
it 'returns bot resolved event end time' do
|
||||
expect(helper.last_non_human_activity(conversation).to_i).to eq(bot_resolved_event.event_end_time.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is reopened after bot resolution' do
|
||||
let(:creation_time) { 5.days.ago }
|
||||
let(:bot_resolution_time) { 5.days.ago + 5.minutes }
|
||||
let(:reopening_time) { 1.hour.ago }
|
||||
|
||||
let!(:conversation) do
|
||||
create(:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
assignee: user,
|
||||
created_at: creation_time)
|
||||
end
|
||||
|
||||
before do
|
||||
# First opened event
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
value: 0,
|
||||
event_start_time: creation_time,
|
||||
event_end_time: creation_time)
|
||||
|
||||
# Bot resolved event
|
||||
create(:reporting_event,
|
||||
name: 'conversation_bot_resolved',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_start_time: creation_time,
|
||||
event_end_time: bot_resolution_time)
|
||||
|
||||
# Resolved event
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_start_time: creation_time,
|
||||
event_end_time: bot_resolution_time)
|
||||
|
||||
# Reopened event
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
value: (reopening_time - bot_resolution_time).to_i,
|
||||
event_start_time: bot_resolution_time,
|
||||
event_end_time: reopening_time)
|
||||
end
|
||||
|
||||
it 'returns the reopening event time, not the creation time' do
|
||||
# This is the key test: last_non_human_activity should return the reopening time
|
||||
# so that first response time is calculated from when the conversation was reopened,
|
||||
# not from when it was originally created
|
||||
expect(helper.last_non_human_activity(conversation).to_i).to eq(reopening_time.to_i)
|
||||
|
||||
# Verify it's not returning the creation time or bot resolution time
|
||||
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(creation_time.to_i)
|
||||
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(bot_resolution_time.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has multiple types of events' do
|
||||
let(:opened_event_time) { 1.hour.ago }
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_bot_resolved',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_end_time: 4.hours.ago)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_bot_handoff',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_end_time: 3.hours.ago)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
event_end_time: opened_event_time)
|
||||
end
|
||||
|
||||
it 'returns the most recent handoff or opened event' do
|
||||
# opened_event is more recent than handoff_event
|
||||
expect(helper.last_non_human_activity(conversation).to_i).to eq(opened_event_time.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has multiple reopenings' do
|
||||
let(:third_opened_time) { 30.minutes.ago }
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
value: 0,
|
||||
event_end_time: 5.days.ago)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
value: 3600,
|
||||
event_end_time: 2.days.ago)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_opened',
|
||||
conversation_id: conversation.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
value: 7200,
|
||||
event_end_time: third_opened_time)
|
||||
end
|
||||
|
||||
it 'returns the most recent opened event' do
|
||||
expect(helper.last_non_human_activity(conversation).to_i).to eq(third_opened_time.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
|
||||
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
|
||||
# Combined message events into one helper
|
||||
let(:message_events) do
|
||||
{
|
||||
dm: build(:instagram_message_create_event).with_indifferent_access,
|
||||
standby: build(:instagram_message_standby_event).with_indifferent_access,
|
||||
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
|
||||
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
|
||||
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
|
||||
story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
|
||||
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
|
||||
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
}
|
||||
end
|
||||
|
||||
def return_object_for(sender_id)
|
||||
{ name: 'Jane',
|
||||
@@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
describe '#perform' do
|
||||
context 'when handling messaging events for Instagram via Facebook page' do
|
||||
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
|
||||
let(:fb_object) { double }
|
||||
|
||||
before do
|
||||
instagram_inbox.destroy
|
||||
end
|
||||
|
||||
it 'creates incoming message in the instagram inbox' do
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
@@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates standby message in the instagram inbox' do
|
||||
standby_event = build(:instagram_message_standby_event).with_indifferent_access
|
||||
sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:standby][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(standby_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
@@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle instagram unsend message event' do
|
||||
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
|
||||
sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{
|
||||
name: 'Jane',
|
||||
@@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||
instagram_webhook.perform_now(unsend_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
|
||||
expect(instagram_messenger_inbox.messages.last.deleted).to be true
|
||||
@@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram inbox' do
|
||||
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
|
||||
sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(attachment_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
@@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram inbox for story mention' do
|
||||
story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
|
||||
sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access,
|
||||
{ story:
|
||||
@@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
id: 'instagram-message-id-1234' }.with_indifferent_access
|
||||
)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.messages.count).to be 1
|
||||
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
|
||||
@@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'does not create contact or messages when Facebook API call fails' do
|
||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||
|
||||
instagram_messenger_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_echo_event[:entry])
|
||||
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 0
|
||||
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
|
||||
@@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle messaging_seen callback' do
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
|
||||
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
|
||||
channel: instagram_messenger_inbox.channel).and_call_original
|
||||
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||
instagram_webhook.perform_now(messaging_seen_event[:entry])
|
||||
end
|
||||
|
||||
it 'handles unsupported message' do
|
||||
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
|
||||
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
return_object_for(sender_id).with_indifferent_access
|
||||
)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||
instagram_messenger_inbox.reload
|
||||
|
||||
instagram_webhook.perform_now(unsupported_event[:entry])
|
||||
expect(instagram_messenger_inbox.contacts.count).to be 1
|
||||
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_messenger_inbox.conversations.count).to be 1
|
||||
@@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
context 'when handling messaging events for Instagram via Instagram login' do
|
||||
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
|
||||
let!(:instagram_inbox) { instagram_channel.inbox }
|
||||
|
||||
before do
|
||||
instagram_channel.update(access_token: 'valid_instagram_token')
|
||||
|
||||
@@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with correct contact info in the instagram direct inbox' do
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
expect(instagram_inbox.contacts.count).to eq 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to eq 1
|
||||
@@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'sets correct instagram attributes on contact' do
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
contact = instagram_inbox.contacts.last
|
||||
@@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handle instagram unsend message event' do
|
||||
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
|
||||
|
||||
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
|
||||
|
||||
# Create attachment correctly with account association
|
||||
@@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do
|
||||
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
|
||||
instagram_webhook.perform_now(message_events[:unsend][:entry])
|
||||
instagram_webhook.perform_now(unsend_event[:entry])
|
||||
|
||||
message.reload
|
||||
|
||||
@@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the instagram direct inbox' do
|
||||
instagram_webhook.perform_now(message_events[:attachment][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(attachment_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.messages.count).to be 1
|
||||
@@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handles unsupported message' do
|
||||
instagram_webhook.perform_now(message_events[:unsupported][:entry])
|
||||
instagram_inbox.reload
|
||||
|
||||
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
|
||||
instagram_webhook.perform_now(unsupported_event[:entry])
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
|
||||
expect(instagram_inbox.conversations.count).to be 1
|
||||
@@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'does not create contact or messages when Instagram API call fails' do
|
||||
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
|
||||
|
||||
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
|
||||
|
||||
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
instagram_webhook.perform_now(story_mention_echo_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 0
|
||||
expect(instagram_inbox.contact_inboxes.count).to be 0
|
||||
@@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do
|
||||
end
|
||||
|
||||
it 'handles messaging_seen callback' do
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
|
||||
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
|
||||
|
||||
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
|
||||
channel: instagram_inbox.channel).and_call_original
|
||||
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
|
||||
instagram_webhook.perform_now(messaging_seen_event[:entry])
|
||||
end
|
||||
|
||||
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
|
||||
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
|
||||
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
|
||||
|
||||
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
|
||||
instagram_webhook.perform_now(message_events[:dm][:entry])
|
||||
|
||||
instagram_inbox.reload
|
||||
dm_event = build(:instagram_message_create_event).with_indifferent_access
|
||||
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
|
||||
instagram_webhook.perform_now(dm_event[:entry])
|
||||
|
||||
expect(instagram_inbox.contacts.count).to be 1
|
||||
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
|
||||
|
||||
@@ -130,6 +130,42 @@ describe AutomationRuleListener do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation_resolved' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_resolved', account: account) }
|
||||
let(:event) do
|
||||
Events::Base.new('conversation_resolved', Time.zone.now, { conversation: conversation,
|
||||
changed_attributes: { status: %w[Snoozed Open] } })
|
||||
end
|
||||
|
||||
context 'when matching rules are present' do
|
||||
it 'calls AutomationRules::ActionService if conditions match' do
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if conditions do not match' do
|
||||
allow(condition_match).to receive(:present?).and_return(false)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
|
||||
it 'calls AutomationRules::ActionService for each rule when multiple rules are present' do
|
||||
create(:automation_rule, event_name: 'conversation_resolved', account: account)
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).twice
|
||||
end
|
||||
|
||||
it 'does not call AutomationRules::ActionService if performed by automation' do
|
||||
event.data[:performed_by] = automation_rule
|
||||
allow(condition_match).to receive(:present?).and_return(true)
|
||||
listener.conversation_resolved(event)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'message_created' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'message_created', account: account) }
|
||||
let!(:message) { create(:message, account: account, conversation: conversation) }
|
||||
|
||||
@@ -267,4 +267,177 @@ describe ReportingEventListener do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_opened' do
|
||||
context 'when conversation is opened for the first time' do
|
||||
let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
it 'creates conversation_opened event with value 0' do
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
||||
listener.conversation_opened(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
|
||||
|
||||
opened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(opened_event.value).to eq 0
|
||||
expect(opened_event.value_in_business_hours).to eq 0
|
||||
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is reopened after being resolved' do
|
||||
let(:resolved_time) { 2.hours.ago }
|
||||
let(:reopened_time) { 1.hour.ago }
|
||||
let(:reopened_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create a resolved event first
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
value: 3600,
|
||||
event_start_time: reopened_conversation.created_at,
|
||||
event_end_time: resolved_time)
|
||||
end
|
||||
|
||||
it 'creates conversation_opened event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
|
||||
end
|
||||
|
||||
it 'calculates correct time since resolution' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600) # 1 hour = 3600 seconds
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(resolved_time)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
|
||||
it 'sets correct attributes for conversation_opened event' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.account_id).to eq(account.id)
|
||||
expect(reopened_event.inbox_id).to eq(inbox.id)
|
||||
expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
|
||||
expect(reopened_event.user_id).to eq(user.id)
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
|
||||
let!(:business_hours_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
|
||||
let!(:business_hours_conversation) do
|
||||
create(:conversation, account: account, inbox: business_hours_inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: business_hours_inbox.id,
|
||||
conversation_id: business_hours_conversation.id,
|
||||
user_id: user.id,
|
||||
value: 3600,
|
||||
event_start_time: business_hours_conversation.created_at,
|
||||
event_end_time: resolved_time)
|
||||
end
|
||||
|
||||
it 'creates conversation_opened event with business hour value' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: business_hours_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value_in_business_hours).to be 18_000.0 # 5 business hours (26 hours total - 21 non-business hours)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has multiple resolutions' do
|
||||
let(:first_resolved_time) { 3.hours.ago }
|
||||
let(:second_resolved_time) { 1.hour.ago }
|
||||
let(:reopened_time) { 30.minutes.ago }
|
||||
let(:multiple_resolution_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create first resolved event
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: multiple_resolution_conversation.id,
|
||||
user_id: user.id,
|
||||
value: 3600,
|
||||
event_start_time: multiple_resolution_conversation.created_at,
|
||||
event_end_time: first_resolved_time)
|
||||
|
||||
# Create second resolved event (more recent)
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: multiple_resolution_conversation.id,
|
||||
user_id: user.id,
|
||||
value: 1800,
|
||||
event_start_time: first_resolved_time,
|
||||
event_end_time: second_resolved_time)
|
||||
end
|
||||
|
||||
it 'uses the most recent resolved event for calculation' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: multiple_resolution_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(1800) # 30 minutes from second resolution
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(second_resolved_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot resolves and conversation is reopened' do
|
||||
# This implicitly tests that the first_response time is correctly calculated
|
||||
# By checking that a conversation reopened event is created with the correct values
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
let(:agent_bot_inbox) { create(:inbox, account: account) }
|
||||
let(:bot_resolved_time) { 2.hours.ago }
|
||||
let(:reopened_time) { 1.hour.ago }
|
||||
let(:bot_conversation) do
|
||||
create(:conversation, account: account, inbox: agent_bot_inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:agent_bot_inbox, agent_bot: agent_bot, inbox: agent_bot_inbox)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: agent_bot_inbox.id,
|
||||
conversation_id: bot_conversation.id,
|
||||
user_id: user.id,
|
||||
event_end_time: bot_resolved_time)
|
||||
end
|
||||
|
||||
it 'creates conversation_opened event for agent bot reopening' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: bot_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600) # 1 hour since resolution
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(bot_resolved_time)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -82,6 +82,16 @@ RSpec.describe Attachment do
|
||||
|
||||
expect(attachment.thumb_url).to be_present
|
||||
end
|
||||
|
||||
it 'handles unrepresentable images gracefully' do
|
||||
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
|
||||
|
||||
allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
|
||||
|
||||
expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
|
||||
expect(attachment.thumb_url).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'meta data handling' do
|
||||
|
||||
@@ -16,6 +16,11 @@ describe Whatsapp::ChannelCreationService do
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Stub the webhook teardown service to prevent HTTP calls during cleanup
|
||||
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
||||
allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
|
||||
allow(teardown_service).to receive(:perform)
|
||||
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
|
||||
|
||||
@@ -10,121 +10,100 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
phone_number_id: 'test_phone_number_id'
|
||||
}
|
||||
end
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
params: params
|
||||
)
|
||||
let(:service) { described_class.new(account: account, params: params) }
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: params[:phone_number_id],
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:channel) { instance_double(Channel::Whatsapp) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:phone_info) do
|
||||
{
|
||||
phone_number_id: params[:phone_number_id],
|
||||
phone_number: '+1234567890',
|
||||
verified: true,
|
||||
business_name: 'Test Business'
|
||||
}
|
||||
end
|
||||
let(:channel) { instance_double(Channel::Whatsapp) }
|
||||
let(:service_doubles) do
|
||||
{
|
||||
token_exchange: instance_double(Whatsapp::TokenExchangeService),
|
||||
phone_info: instance_double(Whatsapp::PhoneInfoService),
|
||||
token_validation: instance_double(Whatsapp::TokenValidationService),
|
||||
channel_creation: instance_double(Whatsapp::ChannelCreationService)
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(GlobalConfig).to receive(:clear_cache)
|
||||
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
|
||||
allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token)
|
||||
# Mock service dependencies
|
||||
token_exchange = instance_double(Whatsapp::TokenExchangeService)
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(token_exchange)
|
||||
allow(token_exchange).to receive(:perform).and_return(access_token)
|
||||
|
||||
phone_service = instance_double(Whatsapp::PhoneInfoService)
|
||||
allow(Whatsapp::PhoneInfoService).to receive(:new)
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
|
||||
allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info)
|
||||
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
|
||||
allow(phone_service).to receive(:perform).and_return(phone_info)
|
||||
|
||||
validation_service = instance_double(Whatsapp::TokenValidationService)
|
||||
allow(Whatsapp::TokenValidationService).to receive(:new)
|
||||
.with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
|
||||
allow(service_doubles[:token_validation]).to receive(:perform)
|
||||
.with(access_token, params[:waba_id]).and_return(validation_service)
|
||||
allow(validation_service).to receive(:perform)
|
||||
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new)
|
||||
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
|
||||
.and_return(service_doubles[:channel_creation])
|
||||
allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
|
||||
.and_return(channel_creation)
|
||||
allow(channel_creation).to receive(:perform).and_return(channel)
|
||||
|
||||
# Webhook setup is now handled in the channel after_create callback
|
||||
# So we stub it at the model level
|
||||
webhook_service = instance_double(Whatsapp::WebhookSetupService)
|
||||
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(webhook_service).to receive(:perform)
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
end
|
||||
|
||||
it 'orchestrates all services in the correct order' do
|
||||
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
|
||||
expect(service_doubles[:phone_info]).to receive(:perform).ordered
|
||||
expect(service_doubles[:token_validation]).to receive(:perform).ordered
|
||||
expect(service_doubles[:channel_creation]).to receive(:perform).ordered
|
||||
it 'creates channel and sets up webhooks' do
|
||||
expect(channel).to receive(:setup_webhooks)
|
||||
|
||||
result = service.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
context 'when required parameters are missing' do
|
||||
it 'raises error when code is blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
params: params.merge(code: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
|
||||
end
|
||||
|
||||
it 'raises error when business_id is blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
params: params.merge(business_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
|
||||
end
|
||||
|
||||
it 'raises error when waba_id is blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
params: params.merge(waba_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
|
||||
end
|
||||
|
||||
it 'raises error when multiple parameters are blank' do
|
||||
service = described_class.new(
|
||||
account: account,
|
||||
params: params.merge(code: '', business_id: '')
|
||||
)
|
||||
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
|
||||
context 'when parameters are invalid' do
|
||||
it 'raises ArgumentError for missing parameters' do
|
||||
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
|
||||
expect { invalid_service.perform }.to raise_error(ArgumentError, /Required parameters are missing/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when any service fails' do
|
||||
it 'logs and re-raises the error' do
|
||||
allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error')
|
||||
context 'when service fails' do
|
||||
it 'logs and re-raises errors' do
|
||||
token_exchange = instance_double(Whatsapp::TokenExchangeService)
|
||||
allow(Whatsapp::TokenExchangeService).to receive(:new).and_return(token_exchange)
|
||||
allow(token_exchange).to receive(:perform).and_raise('Token error')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
|
||||
expect { service.perform }.to raise_error('Token error')
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when webhook setup fails' do
|
||||
# Create a real channel to test the actual webhook failure behavior
|
||||
real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
|
||||
# Mock the channel creation to return our real channel
|
||||
channel_creation = instance_double(Whatsapp::ChannelCreationService)
|
||||
allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation)
|
||||
allow(channel_creation).to receive(:perform).and_return(real_channel)
|
||||
|
||||
# Mock webhook setup to fail
|
||||
allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error')
|
||||
|
||||
# Verify channel is not marked for reauthorization initially
|
||||
expect(real_channel.reauthorization_required?).to be false
|
||||
|
||||
# The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks)
|
||||
result = service.perform
|
||||
expect(result).to eq(real_channel)
|
||||
|
||||
# Verify the channel is now marked for reauthorization
|
||||
expect(real_channel.reauthorization_required?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox_id is provided (reauthorization flow)' do
|
||||
context 'with reauthorization flow' do
|
||||
let(:inbox_id) { 123 }
|
||||
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
|
||||
let(:service_with_inbox) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
params: params,
|
||||
inbox_id: inbox_id
|
||||
)
|
||||
described_class.new(account: account, params: params, inbox_id: inbox_id)
|
||||
end
|
||||
|
||||
before do
|
||||
@@ -137,16 +116,45 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
|
||||
end
|
||||
|
||||
it 'uses ReauthorizationService instead of ChannelCreationService' do
|
||||
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
|
||||
expect(service_doubles[:phone_info]).to receive(:perform).ordered
|
||||
expect(service_doubles[:token_validation]).to receive(:perform).ordered
|
||||
expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
|
||||
expect(service_doubles[:channel_creation]).not_to receive(:perform)
|
||||
it 'uses ReauthorizationService and sets up webhooks' do
|
||||
expect(reauth_service).to receive(:perform)
|
||||
expect(channel).to receive(:setup_webhooks)
|
||||
|
||||
result = service_with_inbox.perform
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
it 'clears reauthorization flag' do
|
||||
inbox = create(:inbox, account: account)
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
inbox.update!(channel: whatsapp_channel)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
|
||||
|
||||
# Mock the ReauthorizationService to return our test channel
|
||||
reauth_service = instance_double(Whatsapp::ReauthorizationService)
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox.id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
|
||||
# Perform the reauthorization and clear the flag
|
||||
allow(reauth_service).to receive(:perform) do
|
||||
whatsapp_channel.reauthorized!
|
||||
whatsapp_channel
|
||||
end
|
||||
|
||||
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
|
||||
|
||||
expect(whatsapp_channel.reauthorization_required?).to be true
|
||||
result = service_with_real_inbox.perform
|
||||
expect(result).to eq(whatsapp_channel)
|
||||
expect(whatsapp_channel.reauthorization_required?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processed_params is nil (parameter-less templates)' do
|
||||
let(:nil_params) do
|
||||
{
|
||||
'processed_params' => nil
|
||||
}
|
||||
end
|
||||
|
||||
let(:parameterless_template) do
|
||||
{
|
||||
'name' => 'test_no_params_template',
|
||||
'language' => 'en',
|
||||
'parameter_format' => 'POSITIONAL',
|
||||
'id' => '9876543210987654',
|
||||
'status' => 'APPROVED',
|
||||
'category' => 'UTILITY',
|
||||
'previous_category' => 'MARKETING',
|
||||
'sub_category' => 'CUSTOM',
|
||||
'components' => [
|
||||
{
|
||||
'type' => 'BODY',
|
||||
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'converts nil to empty enhanced format' do
|
||||
converter = described_class.new(nil_params, parameterless_template)
|
||||
result = converter.normalize_to_enhanced
|
||||
|
||||
expect(result['processed_params']).to eq({})
|
||||
expect(result['format_version']).to eq('legacy')
|
||||
end
|
||||
|
||||
it 'does not raise ArgumentError for nil processed_params' do
|
||||
expect do
|
||||
converter = described_class.new(nil_params, parameterless_template)
|
||||
converter.normalize_to_enhanced
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid format' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
@@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do
|
||||
end
|
||||
|
||||
describe 'simplified conversion methods' do
|
||||
describe '#convert_legacy_to_enhanced' do
|
||||
it 'handles nil processed_params without raising error' do
|
||||
converter = described_class.new({}, template)
|
||||
result = converter.send(:convert_legacy_to_enhanced, nil, template)
|
||||
expect(result).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for parameter-less templates' do
|
||||
parameterless_template = {
|
||||
'name' => 'no_params_template',
|
||||
'language' => 'en',
|
||||
'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }]
|
||||
}
|
||||
|
||||
converter = described_class.new({}, parameterless_template)
|
||||
result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template)
|
||||
expect(result).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#convert_array_to_body_params' do
|
||||
it 'converts empty array' do
|
||||
converter = described_class.new({}, template)
|
||||
|
||||
@@ -5,7 +5,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
create(:channel_whatsapp,
|
||||
phone_number: '+1234567890',
|
||||
provider_config: {
|
||||
'phone_number_id' => 'test_phone_id',
|
||||
'phone_number_id' => '123456789',
|
||||
'webhook_verify_token' => 'test_verify_token'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
@@ -18,9 +18,14 @@ describe Whatsapp::WebhookSetupService do
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
|
||||
before do
|
||||
# Stub webhook teardown to prevent HTTP calls during cleanup
|
||||
stub_request(:delete, /graph.facebook.com/).to_return(status: 200, body: '{}', headers: {})
|
||||
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
# Default stub for phone_number_verified? with any argument
|
||||
allow(api_client).to receive(:phone_number_verified?).and_return(false)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
@@ -148,5 +153,87 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup fails and should trigger reauthorization' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Invalid access token')
|
||||
end
|
||||
|
||||
it 'raises error with webhook setup failure message' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect { service.perform }.to raise_error(/Webhook setup failed: Invalid access token/)
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs the webhook setup failure' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Webhook setup failed: Invalid access token')
|
||||
expect { service.perform }.to raise_error(/Webhook setup failed/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when used during reauthorization flow' do
|
||||
let(:existing_channel) do
|
||||
create(:channel_whatsapp,
|
||||
phone_number: '+1234567890',
|
||||
provider_config: {
|
||||
'phone_number_id' => '123456789',
|
||||
'webhook_verify_token' => 'existing_verify_token',
|
||||
'business_id' => 'existing_business_id',
|
||||
'waba_id' => 'existing_waba_id'
|
||||
},
|
||||
provider: 'whatsapp_cloud',
|
||||
sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
end
|
||||
let(:new_access_token) { 'new_access_token' }
|
||||
let(:service_reauth) { described_class.new(existing_channel, waba_id, new_access_token) }
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'successfully reauthorizes with new access token' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses the existing webhook verify token during reauthorization' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token')
|
||||
service_reauth.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook setup is successful in creation flow' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'completes successfully without errors' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not log any errors' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(Rails.logger).not_to receive(:error)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ properties:
|
||||
enum:
|
||||
- conversation_created
|
||||
- conversation_updated
|
||||
- conversation_resolved
|
||||
- message_created
|
||||
example: message_created
|
||||
description: The event when you want to execute the automation actions
|
||||
|
||||
@@ -30,22 +30,64 @@ properties:
|
||||
example: 1
|
||||
template_params:
|
||||
type: object
|
||||
description: The template params for the message in case of whatsapp Channel
|
||||
description: WhatsApp template parameters for sending structured messages
|
||||
required:
|
||||
- name
|
||||
- category
|
||||
- language
|
||||
- processed_params
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name of the template
|
||||
example: 'sample_issue_resolution'
|
||||
description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager)
|
||||
example: 'purchase_receipt'
|
||||
category:
|
||||
type: string
|
||||
enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION']
|
||||
description: Category of the template
|
||||
example: UTILITY
|
||||
example: 'UTILITY'
|
||||
language:
|
||||
type: string
|
||||
description: Language of the template
|
||||
example: en_US
|
||||
description: Language code of the template (BCP 47 format)
|
||||
example: 'en_US'
|
||||
processed_params:
|
||||
type: object
|
||||
description: The processed param values for template variables in template
|
||||
example:
|
||||
1: 'Chatwoot'
|
||||
description: Processed template parameters organized by component type
|
||||
properties:
|
||||
body:
|
||||
type: object
|
||||
description: Body component parameters with variable placeholders
|
||||
additionalProperties:
|
||||
type: string
|
||||
example:
|
||||
'1': 'Visa'
|
||||
'2': 'Nike'
|
||||
'3': 'Bill'
|
||||
header:
|
||||
type: object
|
||||
description: Header component parameters for media templates
|
||||
properties:
|
||||
media_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers
|
||||
example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
|
||||
media_type:
|
||||
type: string
|
||||
enum: ['image', 'video', 'document']
|
||||
description: Type of media for the header
|
||||
example: 'document'
|
||||
buttons:
|
||||
type: array
|
||||
description: Button component parameters for interactive templates
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: ['url', 'copy_code']
|
||||
description: Type of button parameter
|
||||
parameter:
|
||||
type: string
|
||||
description: Dynamic parameter value for the button
|
||||
example: 'SSFSDFSD'
|
||||
@@ -10,4 +10,4 @@ properties:
|
||||
- type: object
|
||||
description: Single automation rule (for show/create/update endpoints)
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/automation_rule_item'
|
||||
- $ref: '#/components/schemas/automation_rule_item'
|
||||
|
||||
@@ -2,7 +2,57 @@ tags:
|
||||
- Messages
|
||||
operationId: create-a-new-message-in-a-conversation
|
||||
summary: Create New Message
|
||||
description: Create a new message in the conversation
|
||||
description: |
|
||||
Create a new message in the conversation.
|
||||
|
||||
## WhatsApp Template Messages
|
||||
|
||||
For WhatsApp channels, you can send structured template messages using the `template_params` field.
|
||||
Templates must be pre-approved in WhatsApp Business Manager.
|
||||
|
||||
### Example Templates
|
||||
|
||||
**Text with Image Header:**
|
||||
```json
|
||||
{
|
||||
"content": "Hi your order 121212 is confirmed. Please wait for further updates",
|
||||
"template_params": {
|
||||
"name": "order_confirmation",
|
||||
"category": "MARKETING",
|
||||
"language": "en",
|
||||
"processed_params": {
|
||||
"body": {
|
||||
"1": "121212"
|
||||
},
|
||||
"header": {
|
||||
"media_url": "https://picsum.photos/200/300",
|
||||
"media_type": "image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Text with Copy Code Button:**
|
||||
```json
|
||||
{
|
||||
"content": "Special offer! Get 30% off your next purchase. Use the code below",
|
||||
"template_params": {
|
||||
"name": "discount_coupon",
|
||||
"category": "MARKETING",
|
||||
"language": "en",
|
||||
"processed_params": {
|
||||
"body": {
|
||||
"discount_percentage": "30"
|
||||
},
|
||||
"buttons": [{
|
||||
"type": "copy_code",
|
||||
"parameter": "SAVE20"
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
security:
|
||||
- userApiKey: []
|
||||
- agentBotApiKey: []
|
||||
|
||||
+77
-8
@@ -5937,7 +5937,7 @@
|
||||
],
|
||||
"operationId": "create-a-new-message-in-a-conversation",
|
||||
"summary": "Create New Message",
|
||||
"description": "Create a new message in the conversation",
|
||||
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
@@ -10148,28 +10148,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10542,6 +10610,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -4334,7 +4334,7 @@
|
||||
],
|
||||
"operationId": "create-a-new-message-in-a-conversation",
|
||||
"summary": "Create New Message",
|
||||
"description": "Create a new message in the conversation",
|
||||
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
|
||||
"security": [
|
||||
{
|
||||
"userApiKey": []
|
||||
@@ -8509,28 +8509,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8903,6 +8971,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -3132,28 +3132,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3526,6 +3594,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -2547,28 +2547,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2941,6 +3009,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
@@ -3308,28 +3308,96 @@
|
||||
},
|
||||
"template_params": {
|
||||
"type": "object",
|
||||
"description": "The template params for the message in case of whatsapp Channel",
|
||||
"description": "WhatsApp template parameters for sending structured messages",
|
||||
"required": [
|
||||
"name",
|
||||
"category",
|
||||
"language",
|
||||
"processed_params"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the template",
|
||||
"example": "sample_issue_resolution"
|
||||
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
|
||||
"example": "purchase_receipt"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"UTILITY",
|
||||
"MARKETING",
|
||||
"SHIPPING_UPDATE",
|
||||
"TICKET_UPDATE",
|
||||
"ISSUE_RESOLUTION"
|
||||
],
|
||||
"description": "Category of the template",
|
||||
"example": "UTILITY"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language of the template",
|
||||
"description": "Language code of the template (BCP 47 format)",
|
||||
"example": "en_US"
|
||||
},
|
||||
"processed_params": {
|
||||
"type": "object",
|
||||
"description": "The processed param values for template variables in template",
|
||||
"example": {
|
||||
"1": "Chatwoot"
|
||||
"description": "Processed template parameters organized by component type",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "object",
|
||||
"description": "Body component parameters with variable placeholders",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": {
|
||||
"1": "Visa",
|
||||
"2": "Nike",
|
||||
"3": "Bill"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"type": "object",
|
||||
"description": "Header component parameters for media templates",
|
||||
"properties": {
|
||||
"media_url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
|
||||
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
|
||||
},
|
||||
"media_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"image",
|
||||
"video",
|
||||
"document"
|
||||
],
|
||||
"description": "Type of media for the header",
|
||||
"example": "document"
|
||||
}
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"type": "array",
|
||||
"description": "Button component parameters for interactive templates",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"url",
|
||||
"copy_code"
|
||||
],
|
||||
"description": "Type of button parameter"
|
||||
},
|
||||
"parameter": {
|
||||
"type": "string",
|
||||
"description": "Dynamic parameter value for the button",
|
||||
"example": "SSFSDFSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3702,6 +3770,7 @@
|
||||
"enum": [
|
||||
"conversation_created",
|
||||
"conversation_updated",
|
||||
"conversation_resolved",
|
||||
"message_created"
|
||||
],
|
||||
"example": "message_created",
|
||||
|
||||
Reference in New Issue
Block a user