Adds Telnyx SMS provider, enables SMS resubscribe (#570)

This commit is contained in:
Chris Anderson 2024-12-09 08:49:04 -06:00 committed by GitHub
parent 3867511dce
commit f4b78be484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 152 additions and 12 deletions

View file

@ -0,0 +1,107 @@
import App from '../../app'
import { encodeHashid } from '../../utilities'
import { ExternalProviderParams, ProviderControllers, ProviderSchema, ProviderSetupMeta } from '../Provider'
import { createController } from '../ProviderService'
import TextError, { UndeliverableTextError, UnsubscribeTextError } from './TextError'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { TextProvider } from './TextProvider'
/**
* https://developers.telnyx.com/api/messaging/send-message
*/
interface TelnyxDataParams {
api_key: string
phone_number: string
}
interface TelnyxProviderParams extends ExternalProviderParams {
data: TelnyxDataParams
}
export default class TelnyxTextProvider extends TextProvider {
api_key!: string
phone_number!: string
static namespace = 'telnyx'
static meta = {
name: 'Telnyx',
description: '',
url: 'https://telnyx.com',
icon: 'https://parcelvoy.com/providers/telnyx.svg',
}
static schema = ProviderSchema<TelnyxProviderParams, TelnyxDataParams>('telnyxTextProviderParams', {
type: 'object',
required: ['api_key', 'phone_number'],
properties: {
api_key: {
type: 'string',
title: 'API Key',
},
phone_number: { type: 'string' },
},
})
loadSetup(app: App): ProviderSetupMeta[] {
return [{
name: 'Inbound URL',
value: `${app.env.apiBaseUrl}/providers/${encodeHashid(this.id)}/${(this.constructor as any).namespace}/inbound`,
}]
}
async send(message: TextMessage): Promise<TextResponse> {
const { to, text } = message
const { phone_number: from } = this
const response = await fetch('https://api.telnyx.com/v2/messages', {
method: 'POST',
headers: {
Authorization: `Basic ${this.api_key}`,
'Content-Type': 'application/json',
'User-Agent': 'parcelvoy/v1 (+https://github.com/parcelvoy/platform)',
},
body: JSON.stringify({
from,
to,
text,
}),
})
const responseBody = await response.json()
if (response.ok) {
return {
message,
success: true,
response: responseBody.id,
}
} else {
// https://support.telnyx.com/en/articles/6505121-telnyx-messaging-error-codes
const error = responseBody.errors?.[0]
if (error.code === 40300) {
// Unable to send because recipient has unsubscribed
throw new UnsubscribeTextError(this.type, this.phone_number, responseBody.message)
} else if (responseBody.code === 40008) {
// Unable to send because region is not enabled
throw new UndeliverableTextError(this.type, this.phone_number, responseBody.message)
}
throw new TextError(this.type, this.phone_number, responseBody.message)
}
}
// https://www.twilio.com/docs/messaging/guides/webhook-request
parseInbound(inbound: any): InboundTextMessage {
const payload = inbound.data.payload
return {
to: payload.to,
from: payload.from.phone_number,
text: payload.text || '',
}
}
static controllers(): ProviderControllers {
const admin = createController('text', this)
return { admin, public: this.inbound(this.namespace) }
}
}

View file

@ -1,12 +1,13 @@
import Router from '@koa/router'
import { loadTextChannel } from '.'
import { unsubscribeSms } from '../../subscriptions/SubscriptionService'
import { toggleChannelSubscriptions } from '../../subscriptions/SubscriptionService'
import Provider, { ProviderGroup } from '../Provider'
import { InboundTextMessage, TextMessage, TextResponse } from './TextMessage'
import { Context } from 'koa'
import { getUserFromPhone } from '../../users/UserRepository'
import { getProject } from '../../projects/ProjectService'
import { EventPostJob } from '../../jobs'
import { SubscriptionState } from '../../subscriptions/Subscription'
export type TextProviderName = 'nexmo' | 'plivo' | 'twilio' | 'logger'
@ -38,7 +39,12 @@ export abstract class TextProvider extends Provider {
// If the message includes the word STOP unsubscribe immediately
if (message.text.toLowerCase().includes('stop')) {
await unsubscribeSms(project.id, user)
await toggleChannelSubscriptions(project.id, user, 'text')
// If the message includes the word START, re-enable
// SMS messages for the user
} else if (message.text.toLowerCase().includes('start')) {
await toggleChannelSubscriptions(project.id, user, 'text', SubscriptionState.subscribed)
// If the message includes the word HELP, send the help message
} else if (message.text.toLowerCase().includes('help') && project.text_help_message) {

View file

@ -3,6 +3,7 @@ import HttpSMSTextProvider from './HttpSMSProvider'
import LoggerTextProvider from './LoggerTextProvider'
import NexmoTextProvider from './NexmoTextProvider'
import PlivoTextProvider from './PlivoTextProvider'
import TelnyxTextProvider from './TelnyxTextProvider'
import TextChannel from './TextChannel'
import { TextProvider, TextProviderName } from './TextProvider'
import TwilioTextProvider from './TwilioTextProvider'
@ -11,6 +12,7 @@ type TextProviderDerived = { new (): TextProvider } & typeof TextProvider
export const typeMap: Record<string, TextProviderDerived> = {
nexmo: NexmoTextProvider,
plivo: PlivoTextProvider,
telnyx: TelnyxTextProvider,
twilio: TwilioTextProvider,
httpsms: HttpSMSTextProvider,
logger: LoggerTextProvider,

View file

@ -64,15 +64,8 @@ export const updateSubscription = async (id: number, params: Partial<Subscriptio
return await Subscription.updateAndFetch(id, params)
}
export const subscriptionForChannel = async (channel: ChannelType, projectId: number): Promise<Subscription | undefined> => {
return await Subscription.first(qb => qb.where('channel', channel).where('project_id', projectId))
}
export const unsubscribeSms = async (projectId: number, user: User) => {
const subscription = await subscriptionForChannel('text', projectId)
if (user && subscription) {
unsubscribe(user.id, subscription.id)
}
export const subscriptionsForChannel = async (channel: ChannelType, projectId: number): Promise<Subscription[]> => {
return await Subscription.all(qb => qb.where('channel', channel).where('project_id', projectId))
}
export const toggleSubscription = async (userId: number, subscriptionId: number, state = SubscriptionState.unsubscribed): Promise<void> => {
@ -122,6 +115,13 @@ export const toggleSubscription = async (userId: number, subscriptionId: number,
}).queue()
}
export const toggleChannelSubscriptions = async (projectId: number, user: User, channel: ChannelType, state = SubscriptionState.unsubscribed) => {
const subscriptions = await subscriptionsForChannel(channel, projectId)
for (const subscription of subscriptions) {
await toggleSubscription(user.id, subscription.id, state)
}
}
export const unsubscribe = async (userId: number, subscriptionId: number): Promise<void> => {
await toggleSubscription(userId, subscriptionId, SubscriptionState.unsubscribed)
}

View file

@ -0,0 +1,25 @@
# Telnyx
## Setup
Start by creating a new account at [https://telnyx.com](https://telnyx.com). Once your account is created, the following steps will get your account linked to Parcelvoy.
## Outbound
All you need for outbound messages is a phone number that supports SMS.
If you already have a phone number, jump to step four.
1. Go to `Real-Time Communications -> Numbers -> Buy Numbers`
2. From here, you can pick the search criteria you care about for a number. Just make sure the number selected supports SMS (Parcelvoy will not work without it)
3. Purchase the number and copy it down.
4. Next, hit the `Home` button in the top left hand corner of the Telnyx dashboard and copy the `API Key` down.
5. Open a new window and go to your Parcelvoy project settings
6. Navigate to `Integrations` and click the `Add Integration` button.
7. Pick Telnyx from the list of integrations and enter the `API Key` and `Phone Number` from Telnyx.
8. Hit save to create the provider.
You are now setup to send SMS messages using Telnyx. Depending on your needs, you may need to get your number approved, etc but that is outside of this scope.
There is one more step however to make it fully functioning and that is to setup inbound messages so that Parcelvoy is notified of unsubscribes.
## Inbound
Setting up inbound messaging is important to comply with carrier rules and regulations regarding unsubscribing from communications. By default Telnyx automatically manages [opt-outs (unsubscribes)](https://support.telnyx.com/en/articles/1270091-sms-opt-out-keywords-and-stop-words), you just have to listen for the inbound webhook to then register that event in Parcelvoy. An additional benefit to setting up inbound messaging is that you can use the created events to trigger journeys.
To setup inbound SMS for Telnyx, please follow the [instructions on their website](https://support.telnyx.com/en/articles/4348981-receiving-sms-on-your-telnyx-number).

View file

@ -22,7 +22,7 @@ You are now setup to send SMS messages using Twilio. There is one more step howe
Setting up inbound messaging is important to comply with carrier rules and regulations regarding unsubscribing from communications. By default Twilio automatically manages [opt-outs (unsubscribes)](https://support.twilio.com/hc/en-us/articles/360034798533-Getting-Started-with-Advanced-Opt-Out-for-Messaging-Services), you just have to listen for the inbound webhook to then register that event in Parcelvoy. An additional benefit to setting up inbound messaging is that you can use the created events to trigger journeys.
To setup inbound SMS for Twilio, do the following:
1. In Twilip, navigate to `Develop -> Phone Numbers -> Manage -> Active Numbers`.
1. In Twilio, navigate to `Develop -> Phone Numbers -> Manage -> Active Numbers`.
2. Pick the phone number you are using internally.
3. Scroll down to the `Messaging` section.
4. On the line item `A Message Comes In` set the type to `Webhook`, the method to `HTTP POST` and then copy the Inbound URL from your provider into that field.