Explorar el Código

Send platform SMS through a Twilio Messaging Service (#592)

No single phone number reaches every country, and Twilio has no Norwegian SMS
numbers. When the platform account has a Messaging Service, texts go through
it and Twilio picks the sender per country: the name Torqvoice where that is
allowed, a local number where it is not. A workshop's own provider is
unchanged.
Bernt Christian Egeland hace 13 horas
padre
commit
11e00c6e01
Se han modificado 3 ficheros con 88 adiciones y 8 borrados
  1. 48 1
      src/__tests__/lib/platform-sms.test.ts
  2. 27 5
      src/lib/platform-sms.ts
  3. 13 2
      src/lib/sms.ts

+ 48 - 1
src/__tests__/lib/platform-sms.test.ts

@@ -86,7 +86,7 @@ const ownTwilio = new Map([
 ])
 ])
 
 
 /** Every text Twilio was asked to send: which account, from which number, to whom. */
 /** Every text Twilio was asked to send: which account, from which number, to whom. */
-let sent: { account: string; from: string; to: string; body: string }[] = []
+let sent: { account: string; from: string; to: string; body: string; service?: string }[] = []
 let twilioDown = false
 let twilioDown = false
 
 
 const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
 const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
@@ -99,6 +99,9 @@ const fetchMock = vi.fn(async (url: string, init: { body: string }) => {
     from: params.get('From') ?? '',
     from: params.get('From') ?? '',
     to: params.get('To') ?? '',
     to: params.get('To') ?? '',
     body: params.get('Body') ?? '',
     body: params.get('Body') ?? '',
+    ...(params.get('MessagingServiceSid')
+      ? { service: params.get('MessagingServiceSid') ?? '' }
+      : {}),
   })
   })
   return { ok: true, json: async () => ({ sid: `SM${sent.length}`, status: 'queued' }) }
   return { ok: true, json: async () => ({ sid: `SM${sent.length}`, status: 'queued' }) }
 })
 })
@@ -219,6 +222,50 @@ describe('a paid workshop with no SMS provider of its own', () => {
   })
   })
 })
 })
 
 
+describe('a platform account with a Messaging Service', () => {
+  const SERVICE = 'MG00000000000000000000000000000000'
+
+  beforeEach(() => {
+    systemSettings = [
+      ...platformAccount.filter((row) => !row.key.endsWith('phoneNumber')),
+      { key: 'platformSms.twilio.messagingServiceSid', value: SERVICE },
+    ]
+  })
+
+  it('sends through the service, which picks the sender for the country', async () => {
+    await text()
+
+    expect(sent).toEqual([
+      {
+        account: 'ACplatform',
+        from: '',
+        to: CUSTOMER,
+        body: 'Your car is ready',
+        service: SERVICE,
+      },
+    ])
+  })
+
+  it('needs no phone number, and records the text as from Torqvoice', async () => {
+    expect(await getOrgSmsProvider('org')).toBe('twilio')
+    expect(await getOrgSmsPhoneNumber('org')).toBe('Torqvoice')
+  })
+
+  it('uses the service when a number is saved as well', async () => {
+    systemSettings = [...systemSettings, platformAccount[2]]
+
+    await text()
+
+    expect(sent[0]).toMatchObject({ from: '', service: SERVICE })
+  })
+
+  it('still counts against the daily allowance', async () => {
+    await textTimes(30)
+
+    await expect(text()).rejects.toMatchObject({ feature: 'platformSmsDaily', limit: 30 })
+  })
+})
+
 describe('a workshop on the free plan', () => {
 describe('a workshop on the free plan', () => {
   beforeEach(() => {
   beforeEach(() => {
     getFeatures.mockResolvedValue(PLAN_FEATURES.free)
     getFeatures.mockResolvedValue(PLAN_FEATURES.free)

+ 27 - 5
src/lib/platform-sms.ts

@@ -28,8 +28,21 @@ export const PLATFORM_SMS_KEYS = {
   TWILIO_ACCOUNT_SID: 'platformSms.twilio.accountSid',
   TWILIO_ACCOUNT_SID: 'platformSms.twilio.accountSid',
   TWILIO_AUTH_TOKEN: 'platformSms.twilio.authToken',
   TWILIO_AUTH_TOKEN: 'platformSms.twilio.authToken',
   PHONE_NUMBER: 'platformSms.phoneNumber',
   PHONE_NUMBER: 'platformSms.phoneNumber',
+  TWILIO_MESSAGING_SERVICE_SID: 'platformSms.twilio.messagingServiceSid',
 } as const
 } as const
 
 
+/**
+ * Where the send path finds a Twilio Messaging Service to send through instead
+ * of a single number. Only the platform account sets it: no country has one
+ * number that reaches the whole world, so the platform sends through a service
+ * that picks the sender per country, the name "Torqvoice" where that is
+ * allowed and a local number where it is not.
+ */
+export const TWILIO_MESSAGING_SERVICE_KEY = 'sms.twilio.messagingServiceSid'
+
+/** What a text sent through the service is recorded as coming from. */
+export const PLATFORM_SENDER_LABEL = 'Torqvoice'
+
 /**
 /**
  * The platform account under the keys the send path already reads, or null
  * The platform account under the keys the send path already reads, or null
  * when there is none. Cloud only: a self-hosted install has no platform
  * when there is none. Cloud only: a self-hosted install has no platform
@@ -47,16 +60,25 @@ export async function platformSmsAccount(): Promise<Map<string, string> | null>
   const accountSid = stored.get(PLATFORM_SMS_KEYS.TWILIO_ACCOUNT_SID)
   const accountSid = stored.get(PLATFORM_SMS_KEYS.TWILIO_ACCOUNT_SID)
   const authToken = stored.get(PLATFORM_SMS_KEYS.TWILIO_AUTH_TOKEN)
   const authToken = stored.get(PLATFORM_SMS_KEYS.TWILIO_AUTH_TOKEN)
   const phoneNumber = stored.get(PLATFORM_SMS_KEYS.PHONE_NUMBER)
   const phoneNumber = stored.get(PLATFORM_SMS_KEYS.PHONE_NUMBER)
-  // All three or nothing. Half an account cannot send, and offering SMS on
-  // the strength of it would only queue messages that fail.
-  if (!accountSid || !authToken || !phoneNumber) return null
+  const messagingServiceSid = stored.get(PLATFORM_SMS_KEYS.TWILIO_MESSAGING_SERVICE_SID)
+  // The account and something to send from, or nothing. Half an account
+  // cannot send, and offering SMS on the strength of it would only queue
+  // messages that fail.
+  if (!accountSid || !authToken || !(messagingServiceSid || phoneNumber)) return null
 
 
-  return new Map([
+  const settings = new Map<string, string>([
     [ORG_SMS_KEYS.SMS_PROVIDER, 'twilio'],
     [ORG_SMS_KEYS.SMS_PROVIDER, 'twilio'],
-    [ORG_SMS_KEYS.SMS_PHONE_NUMBER, phoneNumber],
     [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID, accountSid],
     [ORG_SMS_KEYS.SMS_TWILIO_ACCOUNT_SID, accountSid],
     [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN, authToken],
     [ORG_SMS_KEYS.SMS_TWILIO_AUTH_TOKEN, authToken],
   ])
   ])
+  // A service wins over a number: the service picks the sender per country.
+  if (messagingServiceSid) {
+    settings.set(TWILIO_MESSAGING_SERVICE_KEY, messagingServiceSid)
+    settings.set(ORG_SMS_KEYS.SMS_PHONE_NUMBER, PLATFORM_SENDER_LABEL)
+  } else if (phoneNumber) {
+    settings.set(ORG_SMS_KEYS.SMS_PHONE_NUMBER, phoneNumber)
+  }
+  return settings
 }
 }
 
 
 function today(now: Date): string {
 function today(now: Date): string {

+ 13 - 2
src/lib/sms.ts

@@ -3,7 +3,11 @@ import { channelSettings } from '@/features/integrations/Lib/messaging'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { ORG_SMS_KEYS } from '@/features/sms/Schema/smsSettingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getFeatures } from './features'
 import { getFeatures } from './features'
-import { platformSmsAccount, withPlatformSmsAllowance } from './platform-sms'
+import {
+  platformSmsAccount,
+  TWILIO_MESSAGING_SERVICE_KEY,
+  withPlatformSmsAllowance,
+} from './platform-sms'
 import { normalizePortalPhone } from './portal-phone'
 import { normalizePortalPhone } from './portal-phone'
 
 
 export type SmsProvider = 'twilio' | 'vonage' | 'telnyx'
 export type SmsProvider = 'twilio' | 'vonage' | 'telnyx'
@@ -88,7 +92,14 @@ async function sendViaTwilio(
   const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
   const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
   const credentials = Buffer.from(`${accountSid}:${authToken}`).toString('base64')
   const credentials = Buffer.from(`${accountSid}:${authToken}`).toString('base64')
 
 
-  const params = new URLSearchParams({ From: from, To: to, Body: body })
+  // The platform account sends through a Messaging Service, which chooses the
+  // sender per country; everyone else sends from their own number.
+  const messagingServiceSid = settings.get(TWILIO_MESSAGING_SERVICE_KEY)
+  const params = new URLSearchParams(
+    messagingServiceSid
+      ? { MessagingServiceSid: messagingServiceSid, To: to, Body: body }
+      : { From: from, To: to, Body: body }
+  )
 
 
   const res = await fetch(url, {
   const res = await fetch(url, {
     method: 'POST',
     method: 'POST',