feat(cdp): twilio integration helpers (#35531)

This commit is contained in:
Marcus Hof
2025-07-25 13:23:30 +02:00
committed by GitHub
parent d2a4a30c5e
commit d90aa77859
20 changed files with 486 additions and 207 deletions

View File

@@ -151,6 +151,7 @@ import {
Survey,
SurveyStatsResponse,
TeamType,
TwilioPhoneNumberType,
UserBasicType,
UserInterviewType,
UserType,
@@ -1052,6 +1053,17 @@ export class ApiRequest {
.withQueryString({ channel_id: channelId })
}
public integrationTwilioPhoneNumbers(
id: IntegrationType['id'],
forceRefresh: boolean,
teamId?: TeamType['id']
): ApiRequest {
return this.integrations(teamId)
.addPathComponent(id)
.addPathComponent('twilio_phone_numbers')
.withQueryString({ force_refresh: forceRefresh })
}
public integrationLinearTeams(id: IntegrationType['id'], teamId?: TeamType['id']): ApiRequest {
return this.integrations(teamId).addPathComponent(id).addPathComponent('linear_teams')
}
@@ -3374,6 +3386,12 @@ const api = {
): Promise<{ channels: SlackChannelType[] }> {
return await new ApiRequest().integrationSlackChannelsById(id, channelId).get()
},
async twilioPhoneNumbers(
id: IntegrationType['id'],
forceRefresh: boolean
): Promise<{ phone_numbers: TwilioPhoneNumberType[]; lastRefreshedAt: string }> {
return await new ApiRequest().integrationTwilioPhoneNumbers(id, forceRefresh).get()
},
async linearTeams(id: IntegrationType['id']): Promise<{ teams: LinearTeamType[] }> {
return await new ApiRequest().integrationLinearTeams(id).get()
},

View File

@@ -15,6 +15,7 @@ import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
import { CyclotronJobInputSchemaType } from '~/types'
import { CyclotronJobInputConfiguration } from '../types'
import { TwilioPhoneNumberPicker } from 'lib/integrations/TwilioIntegrationHelpers'
export type CyclotronJobInputIntegrationFieldProps = {
schema: CyclotronJobInputSchemaType
@@ -139,6 +140,9 @@ export function CyclotronJobInputIntegrationField({
if (schema.integration_field === 'linear_team') {
return <LinearTeamPicker value={value} onChange={(x) => onChange?.(x)} integration={integration} />
}
if (schema.integration_field === 'twilio_phone_number') {
return <TwilioPhoneNumberPicker value={value} onChange={(x) => onChange?.(x)} integration={integration} />
}
return (
<div className="text-danger">
<p>Unsupported integration type: {schema.integration}</p>

View File

@@ -5,6 +5,7 @@ import api from 'lib/api'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import { IntegrationView } from 'lib/integrations/IntegrationView'
import { getIntegrationNameFromKind } from 'lib/integrations/utils'
import { ChannelSetupModal } from 'products/messaging/frontend/Channels/ChannelSetupModal'
import { urls } from 'scenes/urls'
import { CyclotronJobInputSchemaType } from '~/types'
@@ -26,8 +27,8 @@ export function IntegrationChoice({
redirectUrl,
beforeRedirect,
}: IntegrationConfigureProps): JSX.Element | null {
const { integrationsLoading, integrations } = useValues(integrationsLogic)
const { newGoogleCloudKey } = useActions(integrationsLogic)
const { integrationsLoading, integrations, newIntegrationModalKind } = useValues(integrationsLogic)
const { newGoogleCloudKey, openNewIntegrationModal, closeNewIntegrationModal } = useActions(integrationsLogic)
const kind = integration
const integrationsOfKind = integrations?.filter((x) => x.kind === kind)
@@ -90,6 +91,15 @@ export function IntegrationChoice({
},
],
}
: ['twilio'].includes(kind)
? {
items: [
{
label: 'Configure new Twilio account',
onClick: () => openNewIntegrationModal('twilio'),
},
],
}
: {
items: [
{
@@ -138,6 +148,13 @@ export function IntegrationChoice({
) : (
button
)}
<ChannelSetupModal
isOpen={newIntegrationModalKind === 'twilio'}
channelType="twilio"
integration={integrationKind || undefined}
onComplete={closeNewIntegrationModal}
/>
</>
)
}

View File

@@ -0,0 +1,110 @@
import { LemonInputSelect, LemonInputSelectOption, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { usePeriodicRerender } from 'lib/hooks/usePeriodicRerender'
import { useEffect, useMemo } from 'react'
import { IntegrationType, TwilioPhoneNumberType } from '~/types'
import { twilioIntegrationLogic } from './twilioIntegrationLogic'
const getTwilioPhoneNumberOptions = (
twilioPhoneNumbers?: TwilioPhoneNumberType[] | null
): LemonInputSelectOption[] | null => {
return twilioPhoneNumbers
? twilioPhoneNumbers.map((x) => {
const displayLabel = `${x.friendly_name} (${x.sid})`
return {
key: x.phone_number,
labelComponent: (
<span className="flex items-center">
<span>{displayLabel}</span>
</span>
),
label: displayLabel,
}
})
: null
}
export type TwilioPhoneNumberPickerProps = {
integration: IntegrationType
value?: string
onChange?: (value: string | null) => void
disabled?: boolean
}
export function TwilioPhoneNumberPicker({
onChange,
value,
integration,
disabled,
}: TwilioPhoneNumberPickerProps): JSX.Element {
const { twilioPhoneNumbers, allTwilioPhoneNumbersLoading, getPhoneNumberRefreshButtonDisabledReason } = useValues(
twilioIntegrationLogic({ id: integration.id })
)
const { loadAllTwilioPhoneNumbers } = useActions(twilioIntegrationLogic({ id: integration.id }))
usePeriodicRerender(15000) // Re-render every 15 seconds for up-to-date `getPhoneNumberRefreshButtonDisabledReason`
// If twilioPhoneNumbers aren't loaded, make sure we display only the phone number and not the actual underlying value
const twilioPhoneNumberOptions = useMemo(
() => getTwilioPhoneNumberOptions(twilioPhoneNumbers),
[twilioPhoneNumbers]
)
// Sometimes the parent will only store the phone number and not the friendly name, so we need to handle that
const modifiedValue = useMemo(() => {
const phoneNumber = twilioPhoneNumbers.find((x: TwilioPhoneNumberType) => x.phone_number === value)
if (phoneNumber) {
return `${phoneNumber.friendly_name} (${phoneNumber.sid})`
}
return value
}, [value, twilioPhoneNumbers])
useEffect(() => {
if (!disabled) {
loadAllTwilioPhoneNumbers()
}
}, [loadAllTwilioPhoneNumbers, disabled])
return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={value ? [value] : []}
onFocus={() =>
!twilioPhoneNumbers.length && !allTwilioPhoneNumbersLoading && loadAllTwilioPhoneNumbers()
}
disabled={disabled}
mode="single"
data-attr="select-twilio-phone-number"
placeholder="Select a phone number..."
action={{
children: <span className="Link">Refresh phone numbers</span>,
onClick: () => loadAllTwilioPhoneNumbers(true),
disabledReason: getPhoneNumberRefreshButtonDisabledReason(),
}}
emptyStateComponent={
<p className="text-secondary italic p-1">
No phone numbers found. Make sure your Twilio account has phone numbers configured.{' '}
<Link to="https://posthog.com/docs/cdp/destinations/twilio" target="_blank">
See the docs for more information.
</Link>
</p>
}
options={
twilioPhoneNumberOptions ??
(modifiedValue
? [
{
key: value ?? modifiedValue,
label: modifiedValue,
},
]
: [])
}
loading={allTwilioPhoneNumbersLoading}
/>
</>
)
}

View File

@@ -1,5 +1,5 @@
import { lemonToast } from '@posthog/lemon-ui'
import { actions, afterMount, connect, kea, listeners, path, selectors } from 'kea'
import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { router, urlToAction } from 'kea-router'
import api, { getCookie } from 'lib/api'
@@ -27,8 +27,18 @@ export const integrationsLogic = kea<integrationsLogicType>([
callback,
}),
deleteIntegration: (id: number) => ({ id }),
openNewIntegrationModal: (kind: IntegrationKind) => ({ kind }),
closeNewIntegrationModal: true,
}),
reducers({
newIntegrationModalKind: [
null as IntegrationKind | null,
{
openNewIntegrationModal: (_, { kind }: { kind: IntegrationKind }) => kind,
closeNewIntegrationModal: () => null,
},
],
}),
loaders(({ values }) => ({
integrations: [
null as IntegrationType[] | null,

View File

@@ -0,0 +1,60 @@
import { actions, connect, kea, key, path, props, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { dayjs } from 'lib/dayjs'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { TwilioPhoneNumberType } from '~/types'
import type { twilioIntegrationLogicType } from './twilioIntegrationLogicType'
export const TWILIO_CHANNELS_MIN_REFRESH_INTERVAL_MINUTES = 5
export const twilioIntegrationLogic = kea<twilioIntegrationLogicType>([
props({} as { id: number }),
key((props) => props.id),
path((key) => ['lib', 'integrations', 'twilioIntegrationLogic', key]),
connect(() => ({
values: [preflightLogic, ['siteUrlMisconfigured', 'preflight']],
})),
actions({
loadAllTwilioPhoneNumbers: (forceRefresh: boolean = false) => ({ forceRefresh }),
}),
loaders(({ props }) => ({
allTwilioPhoneNumbers: [
null as { phone_numbers: TwilioPhoneNumberType[]; lastRefreshedAt: string } | null,
{
loadAllTwilioPhoneNumbers: async ({ forceRefresh }) => {
return await api.integrations.twilioPhoneNumbers(props.id, forceRefresh)
},
},
],
})),
selectors({
twilioPhoneNumbers: [
(s) => [s.allTwilioPhoneNumbers],
(allTwilioPhoneNumbers: { phone_numbers: TwilioPhoneNumberType[]; lastRefreshedAt: string } | null) => {
return allTwilioPhoneNumbers?.phone_numbers ?? []
},
],
getPhoneNumberRefreshButtonDisabledReason: [
(s) => [s.allTwilioPhoneNumbers],
(allTwilioPhoneNumbers: { phone_numbers: TwilioPhoneNumberType[]; lastRefreshedAt: string } | null) =>
(): string => {
const now = dayjs()
if (allTwilioPhoneNumbers) {
const earliestRefresh = dayjs(allTwilioPhoneNumbers.lastRefreshedAt).add(
TWILIO_CHANNELS_MIN_REFRESH_INTERVAL_MINUTES,
'minutes'
)
if (now.isBefore(earliestRefresh)) {
return `You can refresh the phone numbers again ${earliestRefresh.from(now)}`
}
}
return ''
},
],
}),
])

View File

@@ -11,10 +11,10 @@ import IconSalesforce from 'public/services/salesforce.png'
import IconSlack from 'public/services/slack.png'
import IconSnapchat from 'public/services/snapchat.png'
import IconMetaAds from 'public/services/meta-ads.png'
import IconTwilio from 'public/services/twilio.png'
import { capitalizeFirstLetter } from 'lib/utils'
import { IntegrationKind } from '~/types'
import { IconTwilio } from 'lib/lemon-ui/icons'
export const ICONS: Record<IntegrationKind, any> = {
slack: IconSlack,

View File

@@ -4181,6 +4181,12 @@ export interface SlackChannelType {
is_member: boolean
is_private_without_access?: boolean
}
export interface TwilioPhoneNumberType {
sid: string
phone_number: string
friendly_name: string
}
export interface LinearTeamType {
id: string
name: string

View File

@@ -0,0 +1,59 @@
import { DateTime } from 'luxon'
import { TemplateTester } from '../../test/test-helpers'
import { template } from './twilio.template'
describe('twilio template', () => {
const tester = new TemplateTester(template)
beforeEach(async () => {
await tester.beforeEach()
const fixedTime = DateTime.fromISO('2025-01-01T00:00:00Z').toJSDate()
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.getTime())
})
it('should invoke the function', async () => {
const response = await tester.invoke(
{
twilio_account: {
account_sid: 'sid_12345',
auth_token: 'auth_12345',
},
from_number: '+1234567891',
},
{
event: {
event: 'event-name',
},
person: {
properties: {
phone: '+1234567893',
},
},
}
)
expect(response.error).toBeUndefined()
expect(response.finished).toEqual(false)
expect(response.invocation.queueParameters).toMatchInlineSnapshot(`
{
"body": "To=%2B1234567893&From=%2B1234567891&Body=PostHog%20event%20event-name%20was%20triggered",
"headers": {
"Authorization": "Basic c2lkXzEyMzQ1OmF1dGhfMTIzNDU=",
"Content-Type": "application/x-www-form-urlencoded",
},
"method": "POST",
"type": "fetch",
"url": "https://api.twilio.com/2010-04-01/Accounts/sid_12345/Messages.json",
}
`)
const fetchResponse = await tester.invokeFetchResponse(response.invocation, {
status: 200,
body: { message: 'Hello, world!' },
})
expect(fetchResponse.finished).toBe(true)
expect(fetchResponse.error).toBeUndefined()
})
})

View File

@@ -0,0 +1,108 @@
import { HogFunctionTemplate } from '~/cdp/types'
export const template: HogFunctionTemplate = {
free: true,
status: 'hidden',
type: 'destination',
id: 'template-twilio',
name: 'Twilio SMS',
description: 'Send SMS messages using Twilio',
icon_url: '/static/services/twilio.png',
category: ['Communication'],
code_language: 'hog',
hog: `
let toNumber := inputs.to_number
let message := inputs.message
let fromNumber := inputs.from_number
if (not toNumber) {
throw Error('Recipient phone number is required')
}
if (not message) {
throw Error('SMS message is required')
}
let encodedTo := encodeURLComponent(toNumber)
let encodedFrom := encodeURLComponent(fromNumber)
let encodedSmsBody := encodeURLComponent(message)
let base64EncodedAuth := base64Encode(f'{inputs.twilio_account.account_sid}:{inputs.twilio_account.auth_token}')
let url := f'https://api.twilio.com/2010-04-01/Accounts/{inputs.twilio_account.account_sid}/Messages.json'
let body := f'To={encodedTo}&From={encodedFrom}&Body={encodedSmsBody}'
let payload := {
'method': 'POST',
'headers': {
'Authorization': f'Basic {base64EncodedAuth}',
'Content-Type': 'application/x-www-form-urlencoded'
},
'body': body
}
if (inputs.debug) {
print('Sending SMS', url, payload)
}
// Use the Twilio config from the integration
let res := fetch(url, payload)
if (res.status < 200 or res.status >= 300) {
throw Error(f'Failed to send SMS via Twilio: {res.status} {res.body}')
}
if (inputs.debug) {
print('SMS sent', res)
}
`,
inputs_schema: [
{
key: 'twilio_account',
type: 'integration',
integration: 'twilio',
label: 'Twilio account',
requiredScopes: 'placeholder',
secret: false,
hidden: false,
required: true,
},
{
key: 'from_number',
type: 'integration_field',
integration_key: 'twilio_account',
integration_field: 'twilio_phone_number',
label: 'From Phone Number',
description: 'Your Twilio phone number (e.g. +12292109687)',
secret: false,
hidden: false,
required: true,
},
{
key: 'to_number',
type: 'string',
label: 'Recipient phone number',
secret: false,
required: true,
description: 'Phone number to send the SMS to (in E.164 format, e.g., +1234567890).',
default: '{person.properties.phone}',
},
{
key: 'message',
type: 'string',
label: 'Message',
secret: false,
required: true,
description: 'SMS message content (max 1600 characters).',
default: 'PostHog event {event.event} was triggered',
},
{
key: 'debug',
type: 'boolean',
label: 'Log responses',
description: 'Logs the SMS sending responses for debugging.',
secret: false,
required: false,
default: false,
},
],
}

View File

@@ -8,6 +8,7 @@ import { template as nativeWebhook } from './_destinations/native-webhook/webhoo
import { template as redditAdsTemplate } from './_destinations/reddit_ads/reddit.template'
import { template as snapchatAdsTemplate } from './_destinations/snapchat_ads/snapchat.template'
import { template as tiktokAdsTemplate } from './_destinations/tiktok_ads/tiktok.template'
import { template as twilioTemplate } from './_destinations/twilio/twilio.template'
import { template as webhookTemplate } from './_destinations/webhook/webhook.template'
import { template as incomingWebhookTemplate } from './_sources/webhook/incoming_webhook.template'
import { template as botDetectionTemplate } from './_transformations/bot-detection/bot-detection.template'
@@ -31,6 +32,7 @@ export const HOG_FUNCTION_TEMPLATES_DESTINATIONS: HogFunctionTemplate[] = [
linearTemplate,
googleAdsTemplate,
redditAdsTemplate,
twilioTemplate,
]
export const HOG_FUNCTION_TEMPLATES_TRANSFORMATIONS: HogFunctionTemplate[] = [

View File

@@ -91,18 +91,26 @@ class IntegrationSerializer(serializers.ModelSerializer):
config = validated_data.get("config", {})
account_sid = config.get("account_sid")
auth_token = config.get("auth_token")
phone_number = config.get("phone_number")
if not (account_sid and auth_token and phone_number):
raise ValidationError("Account SID, auth token, and phone number must be provided")
if not (account_sid and auth_token):
raise ValidationError("Account SID and auth token must be provided")
instance = TwilioIntegration.integration_from_keys(
account_sid,
auth_token,
phone_number,
team_id,
request.user,
twilio = TwilioIntegration(
Integration(
id=account_sid,
team_id=team_id,
created_by=request.user,
kind="twilio",
config={
"account_sid": account_sid,
},
sensitive_config={
"auth_token": auth_token,
},
),
)
instance = twilio.integration_from_keys()
return instance
elif validated_data["kind"] in OauthIntegration.supported_kinds:
@@ -209,6 +217,33 @@ class IntegrationViewSet(
cache.set(key, response, 60 * 60) # one hour
return Response(response)
@action(methods=["GET"], detail=True, url_path="twilio_phone_numbers")
def twilio_phone_numbers(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()
twilio = TwilioIntegration(instance)
force_refresh: bool = request.query_params.get("force_refresh", "false").lower() == "true"
key = f"twilio/{instance.integration_id}/phone_numbers"
data = cache.get(key)
if data is not None and not force_refresh:
return Response(data)
response = {
"phone_numbers": [
{
"sid": phone_number["sid"],
"phone_number": phone_number["phone_number"],
"friendly_name": phone_number["friendly_name"],
}
for phone_number in twilio.list_twilio_phone_numbers()
],
"lastRefreshedAt": timezone.now().isoformat(),
}
cache.set(key, response, 60 * 60) # one hour
return Response(response)
@action(methods=["GET"], detail=True, url_path="google_conversion_actions")
def conversion_actions(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()

View File

@@ -12,7 +12,6 @@ from .june.template_june import template as june
from .make.template_make import template as make
from .posthog.template_posthog import template as posthog, TemplatePostHogMigrator
from .aws_kinesis.template_aws_kinesis import template as aws_kinesis
from .twillio.template_twilio import template as twilio
from .discord.template_discord import template as discord
from .salesforce.template_salesforce import template_create as salesforce_create, template_update as salesforce_update
from .mailjet.template_mailjet import (
@@ -62,7 +61,6 @@ HOG_FUNCTION_TEMPLATES = [
attio,
avo,
aws_kinesis,
twilio,
braze,
brevo,
clearbit,

View File

@@ -1,78 +0,0 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
template: HogFunctionTemplate = HogFunctionTemplate(
status="beta",
free=False,
type="destination",
id="template-twilio",
name="Twilio",
description="Send SMS via Twilio when an event occurs.",
icon_url="/static/services/twilio.png",
category=["Custom"],
code_language="hog",
hog="""
let encodedTo := encodeURLComponent(inputs.phoneNumber)
let encodedFrom := encodeURLComponent(inputs.fromPhoneNumber)
let encodedSmsBody := encodeURLComponent(f'{inputs.smsBody} - Event: {event.event} at {toDate(event.timestamp)}')
let base64EncodedAuth := base64Encode(f'{inputs.accountSid}:{inputs.authToken}')
let res := fetch(
f'https://api.twilio.com/2010-04-01/Accounts/{inputs.accountSid}/Messages.json',
{
'method': 'POST',
'headers': {
'Authorization': f'Basic {base64EncodedAuth}',
'Content-Type': 'application/x-www-form-urlencoded'
},
'body': f'To={encodedTo}&From={encodedFrom}&Body={encodedSmsBody}'
}
)
if (res.status >= 200 and res.status < 300) {
print('SMS sent successfully via Twilio!')
} else {
throw Error('Error sending SMS', res)
}
""".strip(),
inputs_schema=[
{
"key": "accountSid",
"type": "string",
"label": "Account SID",
"secret": False,
"required": True,
},
{
"key": "authToken",
"type": "string",
"label": "Auth Token",
"secret": True,
"required": True,
},
{
"key": "fromPhoneNumber",
"type": "string",
"label": "From Phone Number",
"description": "Your Twilio phone number (e.g. +12292109687)",
"secret": False,
"required": True,
},
{
"key": "phoneNumber",
"type": "string",
"label": "Recipient Phone Number",
"description": "The phone number to send SMS to (e.g. +491633950489)",
"secret": False,
"required": True,
},
{
"key": "smsBody",
"type": "string",
"label": "SMS Body Template",
"description": "Limited to 1600 characters - exceeding this will cause failures.",
"default": "Event Notification: {event.event} occurred.",
"secret": False,
"required": True,
},
],
)

View File

@@ -1,68 +0,0 @@
import pytest
from common.hogvm.python.utils import UncaughtHogVMException
from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
from posthog.cdp.templates.twillio.template_twilio import template as template_twilio
from inline_snapshot import snapshot
class TestTemplateTwilio(BaseHogFunctionTemplateTest):
template = template_twilio
def _inputs(self, **kwargs):
inputs = {
"accountSid": "AC123456",
"authToken": "auth_token_123",
"fromPhoneNumber": "+12292109687",
"phoneNumber": "+491633950489",
"smsBody": "Test message",
}
inputs.update(kwargs)
return inputs
def test_function_works(self):
self.mock_fetch_response = lambda *args: {"status": 200} # type: ignore
res = self.run_function(self._inputs())
assert res.result is None
# Verify the fetch call was made with correct parameters
fetch_calls = self.get_mock_fetch_calls()
assert len(fetch_calls) == 1
url, options = fetch_calls[0]
assert url == "https://api.twilio.com/2010-04-01/Accounts/AC123456/Messages.json"
assert options["method"] == "POST"
assert options["headers"]["Content-Type"] == "application/x-www-form-urlencoded"
assert options["headers"]["Authorization"].startswith("Basic ")
assert self.get_mock_print_calls() == snapshot([("SMS sent successfully via Twilio!",)])
def test_function_throws_error_on_bad_status(self):
self.mock_fetch_response = lambda *args: {"status": 400} # type: ignore
with pytest.raises(UncaughtHogVMException) as e:
self.run_function(self._inputs())
assert "Error sending SMS" in str(e.value.message)
def test_function_with_custom_message(self):
self.mock_fetch_response = lambda *args: {"status": 200} # type: ignore
custom_inputs = self._inputs(smsBody="Custom notification: {event.event}")
res = self.run_function(custom_inputs)
assert res.result is None
fetch_calls = self.get_mock_fetch_calls()
assert len(fetch_calls) == 1
# Verify custom message is included in the body
url, options = fetch_calls[0]
assert "Custom%20notification" in options["body"]
def test_function_with_invalid_phone_number(self):
self.mock_fetch_response = lambda *args: {"status": 400, "body": {"message": "Invalid phone number"}} # type: ignore
with pytest.raises(UncaughtHogVMException) as e:
self.run_function(self._inputs(phoneNumber="invalid"))
assert "Error sending SMS" in str(e.value.message)

View File

@@ -1191,35 +1191,43 @@ class MetaAdsIntegration:
class TwilioIntegration:
integration: Integration
twilio_provider: TwilioProvider
def __init__(self, integration: Integration) -> None:
if integration.kind != "twilio":
raise Exception("TwilioIntegration init called with Integration with wrong 'kind'")
self.integration = integration
self.twilio_provider = TwilioProvider(
account_sid=self.integration.config["account_sid"],
auth_token=self.integration.sensitive_config["auth_token"],
)
@classmethod
def integration_from_keys(
cls, account_sid: str, auth_token: str, phone_number: str, team_id: int, created_by: Optional[User] = None
) -> Integration:
twilio_provider = TwilioProvider(account_sid=account_sid, auth_token=auth_token)
is_phone_verified = twilio_provider.verify_phone_number(phone_number)
def list_twilio_phone_numbers(self) -> list[dict]:
twilio_phone_numbers = self.twilio_provider.get_phone_numbers()
if not is_phone_verified:
raise ValidationError({"phone_number": f"Failed to verify ownership of phone number {phone_number}"})
if not twilio_phone_numbers:
raise Exception(f"There was an internal error")
return twilio_phone_numbers
def integration_from_keys(self) -> Integration:
account_info = self.twilio_provider.get_account_info()
if not account_info.get("sid"):
raise ValidationError({"account_info": "Failed to get account info"})
integration, created = Integration.objects.update_or_create(
team_id=team_id,
team_id=self.integration.team_id,
kind="twilio",
integration_id=phone_number,
integration_id=account_info["sid"],
defaults={
"config": {
"account_sid": account_sid,
"phone_number": phone_number,
"account_sid": account_info["sid"],
},
"sensitive_config": {
"auth_token": auth_token,
"auth_token": self.integration.sensitive_config["auth_token"],
},
"created_by": created_by,
"created_by": self.integration.created_by,
},
)
if integration.errors:

View File

@@ -1,3 +1,4 @@
from posthoganalytics import capture_exception
import requests
import logging
@@ -24,15 +25,26 @@ class TwilioProvider:
logger.exception(f"Twilio API error: {e}")
raise
def verify_phone_number(self, phone_number: str) -> bool:
def get_phone_numbers(self) -> list[dict]:
"""
Verify that a phone number is owned by the account.
Get all phone numbers owned by the account.
"""
try:
endpoint = "/IncomingPhoneNumbers.json"
params = {"PhoneNumber": phone_number}
response = self._make_request("GET", endpoint, params=params)
return len(response.get("incoming_phone_numbers", [])) > 0
response = self._make_request("GET", endpoint)
return response.get("incoming_phone_numbers", [])
except requests.exceptions.HTTPError as e:
logger.warning(f"Phone number verification failed. Error: {e}")
return False
capture_exception(Exception(f"TwilioIntegration: Failed to list twilio phone numbers: {e}"))
return []
def get_account_info(self) -> dict:
"""
Get account info.
"""
try:
endpoint = ".json"
response = self._make_request("GET", endpoint)
return response
except requests.exceptions.HTTPError as e:
capture_exception(Exception(f"TwilioIntegration: Failed to get account info: {e}"))
return {}

View File

@@ -28,14 +28,6 @@ export const TwilioSetupModal = (props: TwilioSetupModalLogicProps): JSX.Element
<LemonField name="authToken" label="Auth token">
<LemonInput type="password" />
</LemonField>
<LemonField
name="phoneNumber"
label="Phone Number"
info="Must be an SMS/MMS enabled phone number owned by your Twilio account"
help="Must be E.164 format, e.g. +1234567890"
>
<LemonInput placeholder="+1234567890" />
</LemonField>
<div className="flex justify-end">
<LemonButton
type="primary"

View File

@@ -7,7 +7,6 @@ import { lemonToast } from 'lib/lemon-ui/LemonToast'
import { IntegrationType } from '~/types'
import type { twilioSetupModalLogicType } from './twilioSetupModalLogicType'
import { z } from 'zod'
export interface TwilioSetupModalLogicProps {
integration?: IntegrationType | null
@@ -17,7 +16,6 @@ export interface TwilioSetupModalLogicProps {
export interface TwilioFormType {
accountSid: string
authToken: string
phoneNumber: string
}
export const twilioSetupModalLogic = kea<twilioSetupModalLogicType>([
@@ -32,19 +30,10 @@ export const twilioSetupModalLogic = kea<twilioSetupModalLogicType>([
defaults: {
accountSid: '',
authToken: '',
phoneNumber: '',
},
errors: ({ accountSid, authToken, phoneNumber }) => ({
errors: ({ accountSid, authToken }) => ({
accountSid: accountSid.trim() ? undefined : 'Account SID is required',
authToken: authToken.trim() ? undefined : 'Auth Token is required',
phoneNumber: !phoneNumber
? 'Phone Number is required'
: z
.string()
.regex(/^\+[1-9]\d{1,14}$/, 'Invalid E.164 phone number format.')
.safeParse(phoneNumber).error
? 'Invalid E.164 phone number format.'
: undefined,
}),
submit: async () => {
try {
@@ -53,7 +42,6 @@ export const twilioSetupModalLogic = kea<twilioSetupModalLogicType>([
config: {
account_sid: values.twilioIntegration.accountSid,
auth_token: values.twilioIntegration.authToken,
phone_number: values.twilioIntegration.phoneNumber,
},
})
actions.loadIntegrations()

View File

@@ -18,8 +18,6 @@ export const messageChannelLogic = kea<messageChannelLogicType>([
switch (integration?.kind) {
case 'email':
return integration.config.domain
case 'twilio':
return integration.config.phone_number
default:
return integration.display_name
}
@@ -28,7 +26,7 @@ export const messageChannelLogic = kea<messageChannelLogicType>([
isVerificationRequired: [
() => [(_, props) => props],
({ integration }): boolean => {
return ['email', 'twilio'].includes(integration?.kind)
return ['email'].includes(integration?.kind)
},
],
isVerified: [