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
|
||||
|
||||
def setup_webhooks
|
||||
::Twilio::WebhookSetupService.new(inbox: @inbox).perform
|
||||
::Twilio::WebhookSetupService.new(channel: @twilio_channel).perform
|
||||
end
|
||||
|
||||
def phone_number
|
||||
|
||||
+29
-8
@@ -1,10 +1,10 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
module Api::V1::Accounts::Concerns::InboxHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :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
|
||||
|
||||
def sync_templates
|
||||
@@ -17,15 +17,14 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
render json: fetch_health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
register_channel_webhook
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
@@ -35,10 +34,32 @@ module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
def fetch_health_data
|
||||
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
|
||||
|
||||
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
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
include Api::V1::Accounts::Concerns::InboxHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes)
|
||||
|
||||
@@ -687,6 +687,25 @@
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"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": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"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 BotConfiguration from './components/BotConfiguration.vue';
|
||||
import AccountHealth from './components/AccountHealth.vue';
|
||||
import TwilioHealth from './components/TwilioHealth.vue';
|
||||
import WhatsappManualMigrationDialog from './components/WhatsappManualMigrationDialog.vue';
|
||||
import WhatsappManualMigrationBanner from './components/WhatsappManualMigrationBanner.vue';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
@@ -80,6 +81,7 @@ export default {
|
||||
ColorPicker,
|
||||
SelectInput,
|
||||
AccountHealth,
|
||||
TwilioHealth,
|
||||
WhatsappManualMigrationDialog,
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
@@ -159,6 +161,9 @@ export default {
|
||||
shouldShowWhatsAppConfiguration() {
|
||||
return this.isAWhatsAppCloudChannel;
|
||||
},
|
||||
shouldShowTwilioHealth() {
|
||||
return this.isATwilioChannel && this.inbox.medium === 'sms';
|
||||
},
|
||||
whatsAppAPIProviderName() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
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 (
|
||||
this.isATwilioChannel &&
|
||||
this.inbox.phone_number &&
|
||||
@@ -572,7 +587,7 @@ export default {
|
||||
async fetchHealthData() {
|
||||
if (!this.inbox) return;
|
||||
|
||||
if (!this.isAWhatsAppCloudChannel) {
|
||||
if (!this.isAWhatsAppCloudChannel && !this.shouldShowTwilioHealth) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1408,6 +1423,13 @@ export default {
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'twilio-health'">
|
||||
<TwilioHealth
|
||||
:health-data="healthData"
|
||||
:is-registering-webhook="isRegisteringWebhook"
|
||||
@register-webhook="registerWebhook"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappManualMigrationDialog
|
||||
v-if="showWhatsAppManualMigration"
|
||||
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;
|
||||
}
|
||||
},
|
||||
// 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() {
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
@@ -109,6 +131,7 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
throw error;
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
@@ -119,13 +142,24 @@ export default {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsToggleSection
|
||||
v-model="voiceEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
:class="{ 'pointer-events-none opacity-60': isUpdating }"
|
||||
>
|
||||
<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">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
@@ -186,12 +220,12 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="needsCredentials">
|
||||
<NextButton
|
||||
:disabled="isSubmitDisabled"
|
||||
:is-loading="isUpdating"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
@click="updateVoiceSettings"
|
||||
@click="submitVoiceCredentials"
|
||||
/>
|
||||
</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)
|
||||
auth_credentials = if twilio_channel.api_key_sid.present?
|
||||
# When using api_key_sid, the auth token should be the api_secret_key
|
||||
[twilio_channel.api_key_sid, twilio_channel.auth_token]
|
||||
# Voice channels keep the secret in api_key_secret; SMS channels store it in auth_token.
|
||||
[twilio_channel.api_key_sid, twilio_channel.api_key_secret.presence || twilio_channel.auth_token]
|
||||
else
|
||||
# When using account_sid, the auth token is the account's auth token
|
||||
[twilio_channel.account_sid, twilio_channel.auth_token]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Twilio::WebhookSetupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
pattr_initialize [:inbox!]
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def perform
|
||||
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)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
|
||||
@@ -100,7 +100,7 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
||||
)
|
||||
config = voice_params[:provider_config] || {}
|
||||
|
||||
Current.account.twilio_sms.create!(
|
||||
channel = Current.account.twilio_sms.create!(
|
||||
phone_number: voice_params[:phone_number],
|
||||
account_sid: config[:account_sid],
|
||||
auth_token: config[:auth_token],
|
||||
@@ -109,5 +109,10 @@ module Enterprise::Api::V1::Accounts::InboxesController
|
||||
medium: :sms,
|
||||
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
|
||||
|
||||
@@ -20,6 +20,15 @@ module Enterprise::Channel::TwilioSms
|
||||
)
|
||||
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
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
|
||||
@@ -14,6 +14,40 @@ class Twilio::VoiceWebhookSetupService
|
||||
app_sid
|
||||
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
|
||||
|
||||
def validate_token_credentials!
|
||||
@@ -36,26 +70,6 @@ class Twilio::VoiceWebhookSetupService
|
||||
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
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
details = build_error_details(context, 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)
|
||||
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
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -1262,7 +1262,7 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
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
|
||||
|
||||
@@ -1277,4 +1277,49 @@ RSpec.describe 'Inboxes API', type: :request do
|
||||
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
|
||||
|
||||
@@ -31,6 +31,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||
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",
|
||||
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.body).to include('Voice Inbox')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
end
|
||||
@@ -44,7 +44,7 @@ describe Twilio::WebhookSetupService do
|
||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
|
||||
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)
|
||||
end
|
||||
@@ -53,7 +53,7 @@ describe Twilio::WebhookSetupService do
|
||||
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_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(
|
||||
sms_method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user