mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(cdp): twilio integration helpers (#35531)
This commit is contained in:
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
110
frontend/src/lib/integrations/TwilioIntegrationHelpers.tsx
Normal file
110
frontend/src/lib/integrations/TwilioIntegrationHelpers.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
60
frontend/src/lib/integrations/twilioIntegrationLogic.ts
Normal file
60
frontend/src/lib/integrations/twilioIntegrationLogic.ts
Normal 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 ''
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user