Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73bcc9e515 | ||
|
|
706bc39551 | ||
|
|
b6eb637445 | ||
|
|
19ec392f3c |
@@ -36,7 +36,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
end
|
end
|
||||||
|
|
||||||
def setup_webhooks
|
def setup_webhooks
|
||||||
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
|
::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def phone_number
|
def phone_number
|
||||||
|
|||||||
+29
-8
@@ -1,10 +1,10 @@
|
|||||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
module Api::V1::Accounts::Concerns::InboxHealthManagement
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
before_action :validate_health_supported_channel, only: [:health, :register_webhook]
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_templates
|
def sync_templates
|
||||||
@@ -17,15 +17,14 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|||||||
end
|
end
|
||||||
|
|
||||||
def health
|
def health
|
||||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
render json: fetch_health_data
|
||||||
render json: health_data
|
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def register_webhook
|
def register_webhook
|
||||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
register_channel_webhook
|
||||||
|
|
||||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
@@ -35,10 +34,32 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_whatsapp_cloud_channel
|
def fetch_health_data
|
||||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
return Whatsapp::HealthService.new(@inbox.channel).fetch_health_status if whatsapp_cloud_channel?
|
||||||
|
|
||||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
Twilio::HealthService.new(channel: @inbox.channel).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def register_channel_webhook
|
||||||
|
return Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback if whatsapp_cloud_channel?
|
||||||
|
|
||||||
|
Twilio::WebhookSetupService.new(channel: @inbox.channel).perform
|
||||||
|
# No-op unless voice is enabled; keeps the number's voice webhooks in sync alongside messaging.
|
||||||
|
@inbox.channel.try(:reprovision_voice_webhooks!)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_health_supported_channel
|
||||||
|
return if whatsapp_cloud_channel? || twilio_sms_channel?
|
||||||
|
|
||||||
|
render json: { error: 'Health data only available for WhatsApp Cloud API and Twilio SMS channels' }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def whatsapp_cloud_channel?
|
||||||
|
@inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||||
|
end
|
||||||
|
|
||||||
|
def twilio_sms_channel?
|
||||||
|
@inbox.channel.is_a?(Channel::TwilioSms) && @inbox.channel.sms?
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_channel?
|
def whatsapp_channel?
|
||||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
# we are already handling the authorization in fetch inbox
|
# we are already handling the authorization in fetch inbox
|
||||||
before_action :check_authorization, except: [:show]
|
before_action :check_authorization, except: [:show]
|
||||||
|
|
||||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
include Api::V1::Accounts::Concerns::InboxHealthManagement
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@inboxes = policy_scope(Current.account.inboxes)
|
@inboxes = policy_scope(Current.account.inboxes)
|
||||||
|
|||||||
@@ -687,6 +687,25 @@
|
|||||||
},
|
},
|
||||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||||
"WIDGET_FEATURES": "Widget features",
|
"WIDGET_FEATURES": "Widget features",
|
||||||
|
"TWILIO_HEALTH": {
|
||||||
|
"TITLE": "Webhook configuration",
|
||||||
|
"DESCRIPTION": "Twilio needs these webhooks configured on your number for Chatwoot to receive messages and calls",
|
||||||
|
"NO_DATA": "Health data is not available",
|
||||||
|
"WEBHOOKS": {
|
||||||
|
"MESSAGING": "Messaging webhook",
|
||||||
|
"VOICE": "Voice webhook",
|
||||||
|
"VOICE_STATUS": "Voice status callback",
|
||||||
|
"VOICE_APP": "Outbound calling (TwiML app)"
|
||||||
|
},
|
||||||
|
"WEBHOOK": {
|
||||||
|
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||||
|
"ACTION_REQUIRED": "Webhook not configured",
|
||||||
|
"URL_MISMATCH": "Webhook URL mismatch",
|
||||||
|
"REGISTER_BUTTON": "Register Webhook",
|
||||||
|
"NOT_SET": "not set",
|
||||||
|
"TOOLTIP": "Expected {expected}, currently set to {actual}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ACCOUNT_HEALTH": {
|
"ACCOUNT_HEALTH": {
|
||||||
"TITLE": "Manage your WhatsApp account",
|
"TITLE": "Manage your WhatsApp account",
|
||||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vu
|
|||||||
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
|
||||||
import BotConfiguration from './components/BotConfiguration.vue';
|
import BotConfiguration from './components/BotConfiguration.vue';
|
||||||
import AccountHealth from './components/AccountHealth.vue';
|
import AccountHealth from './components/AccountHealth.vue';
|
||||||
|
import TwilioHealth from './components/TwilioHealth.vue';
|
||||||
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
|
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
|
||||||
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
|
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
|
||||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||||
@@ -80,6 +81,7 @@ export default {
|
|||||||
ColorPicker,
|
ColorPicker,
|
||||||
SelectInput,
|
SelectInput,
|
||||||
AccountHealth,
|
AccountHealth,
|
||||||
|
TwilioHealth,
|
||||||
WhatsappManualMigrationDialog,
|
WhatsappManualMigrationDialog,
|
||||||
WhatsappManualMigrationBanner,
|
WhatsappManualMigrationBanner,
|
||||||
Widget,
|
Widget,
|
||||||
@@ -159,6 +161,9 @@ export default {
|
|||||||
shouldShowWhatsAppConfiguration() {
|
shouldShowWhatsAppConfiguration() {
|
||||||
return this.isAWhatsAppCloudChannel;
|
return this.isAWhatsAppCloudChannel;
|
||||||
},
|
},
|
||||||
|
shouldShowTwilioHealth() {
|
||||||
|
return this.isATwilioChannel && this.inbox.medium === 'sms';
|
||||||
|
},
|
||||||
whatsAppAPIProviderName() {
|
whatsAppAPIProviderName() {
|
||||||
if (this.isAWhatsAppCloudChannel) {
|
if (this.isAWhatsAppCloudChannel) {
|
||||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
|
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
|
||||||
@@ -243,6 +248,16 @@ export default {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.shouldShowTwilioHealth) {
|
||||||
|
visibleToAllChannelTabs = [
|
||||||
|
...visibleToAllChannelTabs,
|
||||||
|
{
|
||||||
|
key: 'twilio-health',
|
||||||
|
name: this.$t('INBOX_MGMT.TABS.ACCOUNT_HEALTH'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.isATwilioChannel &&
|
this.isATwilioChannel &&
|
||||||
this.inbox.phone_number &&
|
this.inbox.phone_number &&
|
||||||
@@ -572,7 +587,7 @@ export default {
|
|||||||
async fetchHealthData() {
|
async fetchHealthData() {
|
||||||
if (!this.inbox) return;
|
if (!this.inbox) return;
|
||||||
|
|
||||||
if (!this.isAWhatsAppCloudChannel) {
|
if (!this.isAWhatsAppCloudChannel && !this.shouldShowTwilioHealth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1408,6 +1423,13 @@ export default {
|
|||||||
@register-webhook="registerWebhook"
|
@register-webhook="registerWebhook"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedTabKey === 'twilio-health'">
|
||||||
|
<TwilioHealth
|
||||||
|
:health-data="healthData"
|
||||||
|
:is-registering-webhook="isRegisteringWebhook"
|
||||||
|
@register-webhook="registerWebhook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<WhatsappManualMigrationDialog
|
<WhatsappManualMigrationDialog
|
||||||
v-if="showWhatsAppManualMigration"
|
v-if="showWhatsAppManualMigration"
|
||||||
ref="whatsappManualMigrationDialog"
|
ref="whatsappManualMigrationDialog"
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import ButtonV4 from 'next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
healthData: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isRegisteringWebhook: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['registerWebhook']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const WEBHOOK_LABELS = {
|
||||||
|
messaging: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.MESSAGING',
|
||||||
|
voice: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE',
|
||||||
|
voice_status: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE_STATUS',
|
||||||
|
voice_app: 'INBOX_MGMT.TWILIO_HEALTH.WEBHOOKS.VOICE_APP',
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhooks = computed(() =>
|
||||||
|
(props.healthData?.webhooks || []).map(webhook => ({
|
||||||
|
...webhook,
|
||||||
|
label: t(WEBHOOK_LABELS[webhook.name]),
|
||||||
|
tooltip: t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.TOOLTIP', {
|
||||||
|
expected: webhook.expected,
|
||||||
|
actual: webhook.actual || t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.NOT_SET'),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRegisterWebhook = () => emit('registerWebhook');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="gap-4 mx-6">
|
||||||
|
<div
|
||||||
|
class="px-5 py-5 space-y-6 rounded-xl outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-heading-3 text-n-slate-12">
|
||||||
|
{{ t('INBOX_MGMT.TWILIO_HEALTH.TITLE') }}
|
||||||
|
</span>
|
||||||
|
<p class="mt-1 text-body-main text-n-slate-11">
|
||||||
|
{{ t('INBOX_MGMT.TWILIO_HEALTH.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="healthData" class="grid grid-cols-1 gap-4 xs:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="webhook in webhooks"
|
||||||
|
:key="webhook.name"
|
||||||
|
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<span class="text-body-main font-medium text-n-slate-11">
|
||||||
|
{{ webhook.label }}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
v-tooltip.top="webhook.tooltip"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 justify-between items-center">
|
||||||
|
<span
|
||||||
|
v-if="webhook.configured"
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||||
|
{{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||||
|
{{
|
||||||
|
webhook.actual
|
||||||
|
? t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||||
|
: t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<ButtonV4
|
||||||
|
v-if="!webhook.configured"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
blue
|
||||||
|
:loading="isRegisteringWebhook"
|
||||||
|
:disabled="isRegisteringWebhook"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click="handleRegisterWebhook"
|
||||||
|
>
|
||||||
|
{{ t('INBOX_MGMT.TWILIO_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||||
|
</ButtonV4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="pt-8">
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center p-8 text-center text-n-slate-11"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
|
||||||
|
<p class="text-body-main text-n-slate-11">
|
||||||
|
{{ t('INBOX_MGMT.TWILIO_HEALTH.NO_DATA') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+43
-9
@@ -88,6 +88,28 @@ export default {
|
|||||||
this.isTogglingInbound = false;
|
this.isTogglingInbound = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Saves on toggle, except when enabling still needs API key credentials: then we
|
||||||
|
// reveal the inputs and wait for the user to submit them.
|
||||||
|
async handleVoiceToggle(newValue) {
|
||||||
|
if (this.isUpdating) return;
|
||||||
|
const previousValue = this.voiceEnabled;
|
||||||
|
this.voiceEnabled = newValue;
|
||||||
|
|
||||||
|
if (this.needsCredentials) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.updateVoiceSettings();
|
||||||
|
} catch (_) {
|
||||||
|
this.voiceEnabled = previousValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitVoiceCredentials() {
|
||||||
|
try {
|
||||||
|
await this.updateVoiceSettings();
|
||||||
|
} catch (_) {
|
||||||
|
this.voiceEnabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
async updateVoiceSettings() {
|
async updateVoiceSettings() {
|
||||||
this.isUpdating = true;
|
this.isUpdating = true;
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +131,7 @@ export default {
|
|||||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdating = false;
|
this.isUpdating = false;
|
||||||
}
|
}
|
||||||
@@ -119,13 +142,24 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<SettingsToggleSection
|
<div
|
||||||
v-model="voiceEnabled"
|
class="relative"
|
||||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
|
:class="{ 'pointer-events-none opacity-60': isUpdating }"
|
||||||
:description="
|
>
|
||||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
|
<SettingsToggleSection
|
||||||
"
|
:model-value="voiceEnabled"
|
||||||
/>
|
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
|
||||||
|
:description="
|
||||||
|
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
|
||||||
|
"
|
||||||
|
:hide-toggle="isUpdating"
|
||||||
|
@update:model-value="handleVoiceToggle"
|
||||||
|
>
|
||||||
|
<template v-if="isUpdating" #hiddenToggle>
|
||||||
|
<Spinner class="size-4 text-n-slate-11" />
|
||||||
|
</template>
|
||||||
|
</SettingsToggleSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
|
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
@@ -186,12 +220,12 @@ export default {
|
|||||||
</SettingsFieldSection>
|
</SettingsFieldSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="needsCredentials">
|
||||||
<NextButton
|
<NextButton
|
||||||
:disabled="isSubmitDisabled"
|
:disabled="isSubmitDisabled"
|
||||||
:is-loading="isUpdating"
|
:is-loading="isUpdating"
|
||||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||||
@click="updateVoiceSettings"
|
@click="submitVoiceCredentials"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
class Twilio::HealthService
|
||||||
|
include Rails.application.routes.url_helpers
|
||||||
|
|
||||||
|
pattr_initialize [:channel!]
|
||||||
|
|
||||||
|
# Our Twilio routes are POST-only, so a matching URL on the wrong HTTP method never reaches us.
|
||||||
|
HTTP_METHOD = 'POST'.freeze
|
||||||
|
|
||||||
|
# Compares the webhooks Twilio actually has against the ones Chatwoot expects.
|
||||||
|
# Errors (bad credentials, unknown number) bubble up to the controller as a 422.
|
||||||
|
def perform
|
||||||
|
webhooks = channel.messaging_service_sid? ? messaging_service_webhooks : phone_number_webhooks
|
||||||
|
|
||||||
|
{
|
||||||
|
status: webhooks.all? { |webhook| webhook[:configured] } ? 'healthy' : 'misconfigured',
|
||||||
|
webhooks: webhooks
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def messaging_service_webhooks
|
||||||
|
service = channel.client.messaging.services(channel.messaging_service_sid).fetch
|
||||||
|
# With use_inbound_webhook_on_number set, Twilio prefers the number's webhook over ours.
|
||||||
|
configured = service.inbound_method == HTTP_METHOD && !service.use_inbound_webhook_on_number
|
||||||
|
|
||||||
|
[webhook('messaging', twilio_callback_index_url, service.inbound_request_url, extra: configured)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def phone_number_webhooks
|
||||||
|
number = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number).first
|
||||||
|
raise "Phone number #{channel.phone_number} was not found in the connected Twilio account" if number.nil?
|
||||||
|
|
||||||
|
webhooks = [webhook('messaging', twilio_callback_index_url, number.sms_url, extra: number.sms_method == HTTP_METHOD)]
|
||||||
|
webhooks += voice_webhooks(number) if channel.voice_enabled?
|
||||||
|
webhooks
|
||||||
|
end
|
||||||
|
|
||||||
|
def voice_webhooks(number)
|
||||||
|
[
|
||||||
|
webhook('voice', channel.voice_call_webhook_url, number.voice_url, extra: number.voice_method == HTTP_METHOD),
|
||||||
|
webhook('voice_status', channel.voice_status_webhook_url, number.status_callback,
|
||||||
|
extra: number.status_callback_method == HTTP_METHOD),
|
||||||
|
# Outbound calls dial through the TwiML app, so a stale voice_url here breaks them silently.
|
||||||
|
twiml_app_webhook
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def twiml_app_webhook
|
||||||
|
return webhook('voice_app', channel.voice_call_webhook_url, nil) if channel.twiml_app_sid.blank?
|
||||||
|
|
||||||
|
app = channel.client.applications(channel.twiml_app_sid).fetch
|
||||||
|
webhook('voice_app', channel.voice_call_webhook_url, app.voice_url, extra: app.voice_method == HTTP_METHOD)
|
||||||
|
end
|
||||||
|
|
||||||
|
def webhook(name, expected, actual, extra: false)
|
||||||
|
{ name: name, expected: expected, actual: actual.presence, configured: expected == actual && extra }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -172,8 +172,8 @@ class Twilio::IncomingMessageService
|
|||||||
|
|
||||||
def download_with_auth(media_url)
|
def download_with_auth(media_url)
|
||||||
auth_credentials = if twilio_channel.api_key_sid.present?
|
auth_credentials = if twilio_channel.api_key_sid.present?
|
||||||
# When using api_key_sid, the auth token should be the api_secret_key
|
# Voice channels keep the secret in api_key_secret; SMS channels store it in auth_token.
|
||||||
[twilio_channel.api_key_sid, twilio_channel.auth_token]
|
[twilio_channel.api_key_sid, twilio_channel.api_key_secret.presence || twilio_channel.auth_token]
|
||||||
else
|
else
|
||||||
# When using account_sid, the auth token is the account's auth token
|
# When using account_sid, the auth token is the account's auth token
|
||||||
[twilio_channel.account_sid, twilio_channel.auth_token]
|
[twilio_channel.account_sid, twilio_channel.auth_token]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class Twilio::WebhookSetupService
|
class Twilio::WebhookSetupService
|
||||||
include Rails.application.routes.url_helpers
|
include Rails.application.routes.url_helpers
|
||||||
|
|
||||||
pattr_initialize [:inbox!]
|
pattr_initialize [:channel!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
if channel.messaging_service_sid?
|
if channel.messaging_service_sid?
|
||||||
@@ -41,10 +41,6 @@ class Twilio::WebhookSetupService
|
|||||||
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||||
end
|
end
|
||||||
|
|
||||||
def channel
|
|
||||||
@channel ||= inbox.channel
|
|
||||||
end
|
|
||||||
|
|
||||||
def twilio_client
|
def twilio_client
|
||||||
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
|||||||
)
|
)
|
||||||
config = voice_params[:provider_config] || {}
|
config = voice_params[:provider_config] || {}
|
||||||
|
|
||||||
Current.account.twilio_sms.create!(
|
channel = Current.account.twilio_sms.create!(
|
||||||
phone_number: voice_params[:phone_number],
|
phone_number: voice_params[:phone_number],
|
||||||
account_sid: config[:account_sid],
|
account_sid: config[:account_sid],
|
||||||
auth_token: config[:auth_token],
|
auth_token: config[:auth_token],
|
||||||
@@ -109,5 +109,10 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
|||||||
medium: :sms,
|
medium: :sms,
|
||||||
voice_enabled: true
|
voice_enabled: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A voice channel is an SMS channel with voice_enabled, so it needs the messaging
|
||||||
|
# webhook too. Voice webhooks are provisioned by the model's provision_twiml_app hook.
|
||||||
|
::Twilio::WebhookSetupService.new(channel: channel).perform
|
||||||
|
channel
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ module Enterprise::Channel::TwilioSms
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Re-points the TwiML app and the number at our voice webhooks, reusing the existing app.
|
||||||
|
def reprovision_voice_webhooks!
|
||||||
|
return unless voice_enabled?
|
||||||
|
|
||||||
|
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||||
|
update!(twiml_app_sid: service.sync_twiml_app!)
|
||||||
|
service.configure_number_webhooks!
|
||||||
|
end
|
||||||
|
|
||||||
def voice_call_webhook_url
|
def voice_call_webhook_url
|
||||||
digits = phone_number.delete_prefix('+')
|
digits = phone_number.delete_prefix('+')
|
||||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||||
|
|||||||
@@ -14,6 +14,40 @@ class Twilio::VoiceWebhookSetupService
|
|||||||
app_sid
|
app_sid
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Outbound calls dial through the TwiML app, so its voice_url must track the current host too.
|
||||||
|
def sync_twiml_app!
|
||||||
|
return create_twiml_app! if channel.twiml_app_sid.blank?
|
||||||
|
|
||||||
|
channel.client.applications(channel.twiml_app_sid).update(
|
||||||
|
voice_url: channel.voice_call_webhook_url,
|
||||||
|
voice_method: HTTP_METHOD
|
||||||
|
)
|
||||||
|
channel.twiml_app_sid
|
||||||
|
rescue StandardError => e
|
||||||
|
log_twilio_error('TWIML_APP_UPDATE', e)
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
def configure_number_webhooks!
|
||||||
|
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||||
|
if numbers.empty?
|
||||||
|
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
channel.client
|
||||||
|
.incoming_phone_numbers(numbers.first.sid)
|
||||||
|
.update(
|
||||||
|
voice_url: channel.voice_call_webhook_url,
|
||||||
|
voice_method: HTTP_METHOD,
|
||||||
|
status_callback: channel.voice_status_webhook_url,
|
||||||
|
status_callback_method: HTTP_METHOD
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_token_credentials!
|
def validate_token_credentials!
|
||||||
@@ -36,26 +70,6 @@ class Twilio::VoiceWebhookSetupService
|
|||||||
raise
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
def configure_number_webhooks!
|
|
||||||
numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
|
||||||
if numbers.empty?
|
|
||||||
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
channel.client
|
|
||||||
.incoming_phone_numbers(numbers.first.sid)
|
|
||||||
.update(
|
|
||||||
voice_url: channel.voice_call_webhook_url,
|
|
||||||
voice_method: HTTP_METHOD,
|
|
||||||
status_callback: channel.voice_status_webhook_url,
|
|
||||||
status_callback_method: HTTP_METHOD
|
|
||||||
)
|
|
||||||
rescue StandardError => e
|
|
||||||
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
|
|
||||||
raise
|
|
||||||
end
|
|
||||||
|
|
||||||
def log_twilio_error(context, error)
|
def log_twilio_error(context, error)
|
||||||
details = build_error_details(context, error)
|
details = build_error_details(context, error)
|
||||||
add_twilio_specific_details(details, error)
|
add_twilio_specific_details(details, error)
|
||||||
|
|||||||
@@ -1233,7 +1233,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
json_response = response.parsed_body
|
json_response = response.parsed_body
|
||||||
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
|
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns bad request error for agent' do
|
it 'returns bad request error for agent' do
|
||||||
@@ -1245,7 +1245,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
json_response = response.parsed_body
|
json_response = response.parsed_body
|
||||||
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
|
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1262,7 +1262,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
json_response = response.parsed_body
|
json_response = response.parsed_body
|
||||||
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
|
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API and Twilio SMS channels')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1277,4 +1277,49 @@ RSpec.describe 'Inboxes API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'Twilio inbox health' do
|
||||||
|
let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account) }
|
||||||
|
let(:twilio_inbox) { create(:inbox, account: account, channel: twilio_channel) }
|
||||||
|
let(:health_service) { instance_double(Twilio::HealthService) }
|
||||||
|
let(:health_data) do
|
||||||
|
{ status: 'misconfigured', webhooks: [{ name: 'messaging', configured: false }] }
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:webhook_service) { instance_double(Twilio::WebhookSetupService, perform: true) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::HealthService).to receive(:new).with(channel: twilio_channel).and_return(health_service)
|
||||||
|
allow(health_service).to receive(:perform).and_return(health_data)
|
||||||
|
allow(Twilio::WebhookSetupService).to receive(:new).with(channel: twilio_channel).and_return(webhook_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the twilio webhook health' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}/health",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.parsed_body['status']).to eq('misconfigured')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns bad request for a twilio whatsapp inbox' do
|
||||||
|
whatsapp_medium_inbox = create(:inbox, account: account, channel: create(:channel_twilio_sms, :whatsapp, account: account))
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_medium_inbox.id}/health",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:bad_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'registers the messaging webhook' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}/register_webhook",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(webhook_service).to have_received(:perform)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
headers: { 'Content-Type' => 'application/json' })
|
headers: { 'Content-Type' => 'application/json' })
|
||||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||||
perform: "AP#{SecureRandom.hex(16)}"))
|
perform: "AP#{SecureRandom.hex(16)}"))
|
||||||
|
messaging_webhook_service = instance_double(Twilio::WebhookSetupService, perform: true)
|
||||||
|
allow(Twilio::WebhookSetupService).to receive(:new).and_return(messaging_webhook_service)
|
||||||
|
|
||||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -45,6 +47,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(response.body).to include('Voice Inbox')
|
expect(response.body).to include('Voice Inbox')
|
||||||
expect(response.body).to include('+15551234567')
|
expect(response.body).to include('+15551234567')
|
||||||
|
# the number must also receive SMS, not just calls
|
||||||
|
expect(messaging_webhook_service).to have_received(:perform)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Twilio::HealthService do
|
||||||
|
include Rails.application.routes.url_helpers
|
||||||
|
|
||||||
|
let(:number_instance) { Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberInstance }
|
||||||
|
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||||
|
let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) }
|
||||||
|
let(:twiml_app) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) }
|
||||||
|
let(:channel) { create(:channel_twilio_sms, :with_voice) }
|
||||||
|
let(:number) do
|
||||||
|
instance_double(number_instance, sms_url: twilio_callback_index_url, sms_method: 'POST',
|
||||||
|
voice_url: channel.voice_call_webhook_url, voice_method: 'POST',
|
||||||
|
status_callback: channel.voice_status_webhook_url, status_callback_method: 'POST')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||||
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||||
|
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||||
|
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
|
||||||
|
allow(numbers_list).to receive(:list).and_return([number])
|
||||||
|
allow(twilio_client).to receive(:applications).and_return(twiml_app)
|
||||||
|
allow(twiml_app).to receive(:fetch).and_return(
|
||||||
|
instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationInstance,
|
||||||
|
voice_url: twiml_app_voice_url, voice_method: 'POST')
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'when everything is registered' do
|
||||||
|
let(:twiml_app_voice_url) { channel.voice_call_webhook_url }
|
||||||
|
|
||||||
|
it 'reports healthy' do
|
||||||
|
result = described_class.new(channel: channel).perform
|
||||||
|
|
||||||
|
expect(result[:status]).to eq('healthy')
|
||||||
|
expect(result[:webhooks].map { |webhook| webhook[:name] }).to eq(%w[messaging voice voice_status voice_app])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the twiml app points at a stale host' do
|
||||||
|
let(:twiml_app_voice_url) { 'https://old-host.example.com/twilio/voice/call/15551234567' }
|
||||||
|
|
||||||
|
it 'flags outbound calling as misconfigured even though the number is fine' do
|
||||||
|
result = described_class.new(channel: channel).perform
|
||||||
|
|
||||||
|
expect(result[:status]).to eq('misconfigured')
|
||||||
|
expect(result[:webhooks]).to include(hash_including(name: 'voice', configured: true),
|
||||||
|
hash_including(name: 'voice_app', configured: false))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Twilio::IncomingMessageService do
|
||||||
|
# Voice channels keep the API key secret in api_key_secret, unlike SMS channels which reuse auth_token.
|
||||||
|
describe '#perform' do
|
||||||
|
let(:voice_channel) { create(:channel_twilio_sms, :with_voice) }
|
||||||
|
let(:params_with_attachment) do
|
||||||
|
{
|
||||||
|
SmsSid: 'SMvoice',
|
||||||
|
From: '+12345',
|
||||||
|
To: voice_channel.phone_number,
|
||||||
|
AccountSid: voice_channel.account_sid,
|
||||||
|
Body: 'mms on a voice inbox',
|
||||||
|
NumMedia: '1',
|
||||||
|
MediaContentType0: 'image/jpeg',
|
||||||
|
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||||
|
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||||
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
|
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'downloads the media using the api key secret, not the account auth token' do
|
||||||
|
allow(Down).to receive(:download).and_call_original
|
||||||
|
|
||||||
|
described_class.new(params: params_with_attachment).perform
|
||||||
|
|
||||||
|
expect(Down).to have_received(:download).with(
|
||||||
|
'https://chatwoot-assets.local/sample.png',
|
||||||
|
http_basic_authentication: [voice_channel.api_key_sid, voice_channel.api_key_secret]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
NUMBER_INSTANCE = Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberInstance
|
||||||
|
NUMBER_LIST = Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList
|
||||||
|
|
||||||
|
describe Twilio::HealthService do
|
||||||
|
include Rails.application.routes.url_helpers
|
||||||
|
|
||||||
|
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||||
|
let(:numbers_list) { instance_double(NUMBER_LIST) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
|
||||||
|
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'with a phone number' do
|
||||||
|
let(:channel) { create(:channel_twilio_sms, :with_phone_number) }
|
||||||
|
let(:sms_url) { twilio_callback_index_url }
|
||||||
|
let(:sms_method) { 'POST' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(numbers_list).to receive(:list).and_return([instance_double(NUMBER_INSTANCE, sms_url: sms_url, sms_method: sms_method)])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports healthy when the messaging webhook points at chatwoot over POST' do
|
||||||
|
result = described_class.new(channel: channel).perform
|
||||||
|
|
||||||
|
expect(result[:status]).to eq('healthy')
|
||||||
|
expect(result[:webhooks]).to contain_exactly(hash_including(name: 'messaging', configured: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the messaging webhook points elsewhere' do
|
||||||
|
let(:sms_url) { 'https://demo.twilio.com/welcome/sms/reply' }
|
||||||
|
|
||||||
|
it 'reports misconfigured with the current url' do
|
||||||
|
result = described_class.new(channel: channel).perform
|
||||||
|
|
||||||
|
expect(result[:status]).to eq('misconfigured')
|
||||||
|
expect(result[:webhooks].first).to include(configured: false, expected: twilio_callback_index_url, actual: sms_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the number is missing from the twilio account' do
|
||||||
|
before { allow(numbers_list).to receive(:list).and_return([]) }
|
||||||
|
|
||||||
|
it 'raises' do
|
||||||
|
expect { described_class.new(channel: channel).perform }.to raise_error(/was not found/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a messaging service' do
|
||||||
|
let(:channel) { create(:channel_twilio_sms) }
|
||||||
|
let(:messaging) { instance_double(Twilio::REST::Messaging) }
|
||||||
|
let(:services) { instance_double(Twilio::REST::Messaging::V1::ServiceContext) }
|
||||||
|
let(:use_inbound_webhook_on_number) { false }
|
||||||
|
let(:service) do
|
||||||
|
instance_double(Twilio::REST::Messaging::V1::ServiceInstance,
|
||||||
|
inbound_request_url: twilio_callback_index_url, inbound_method: 'POST',
|
||||||
|
use_inbound_webhook_on_number: use_inbound_webhook_on_number)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(twilio_client).to receive(:messaging).and_return(messaging)
|
||||||
|
allow(messaging).to receive(:services).with(channel.messaging_service_sid).and_return(services)
|
||||||
|
allow(services).to receive(:fetch).and_return(service)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'checks the inbound request url of the messaging service' do
|
||||||
|
result = described_class.new(channel: channel).perform
|
||||||
|
|
||||||
|
expect(result[:status]).to eq('healthy')
|
||||||
|
expect(result[:webhooks].first).to include(name: 'messaging', configured: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the service still defers to the number webhook' do
|
||||||
|
let(:use_inbound_webhook_on_number) { true }
|
||||||
|
|
||||||
|
it 'reports misconfigured because twilio would bypass our inbound url' do
|
||||||
|
expect(described_class.new(channel: channel).perform[:status]).to eq('misconfigured')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -23,7 +23,7 @@ describe Twilio::WebhookSetupService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the messaging service' do
|
it 'updates the messaging service' do
|
||||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
described_class.new(channel: channel_twilio_sms).perform
|
||||||
|
|
||||||
expect(services).to have_received(:update)
|
expect(services).to have_received(:update)
|
||||||
end
|
end
|
||||||
@@ -44,7 +44,7 @@ describe Twilio::WebhookSetupService do
|
|||||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
||||||
allow(phone_double).to receive(:list).and_return([])
|
allow(phone_double).to receive(:list).and_return([])
|
||||||
|
|
||||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
described_class.new(channel: channel_twilio_sms).perform
|
||||||
|
|
||||||
expect(phone_double).not_to have_received(:update)
|
expect(phone_double).not_to have_received(:update)
|
||||||
end
|
end
|
||||||
@@ -53,7 +53,7 @@ describe Twilio::WebhookSetupService do
|
|||||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
||||||
allow(phone_double).to receive(:list).and_return([phone_record_double])
|
allow(phone_double).to receive(:list).and_return([phone_record_double])
|
||||||
|
|
||||||
described_class.new(inbox: channel_twilio_sms.inbox).perform
|
described_class.new(channel: channel_twilio_sms).perform
|
||||||
|
|
||||||
expect(phone_double).to have_received(:update).with(
|
expect(phone_double).to have_received(:update).with(
|
||||||
sms_method: 'POST',
|
sms_method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user