d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
99 lines
3.6 KiB
TypeScript
99 lines
3.6 KiB
TypeScript
import { isRecordLike } from '@sim/utils/object'
|
|
import { verifyTwilioAuth } from '@/lib/webhooks/providers/twilio-signature'
|
|
import type {
|
|
AuthContext,
|
|
EventMatchContext,
|
|
FormatInputContext,
|
|
FormatInputResult,
|
|
WebhookProviderHandler,
|
|
} from '@/lib/webhooks/providers/types'
|
|
|
|
/**
|
|
* Build the media array from Twilio's `NumMedia` / `MediaUrl{N}` /
|
|
* `MediaContentType{N}` params (MMS messages).
|
|
*/
|
|
function extractMedia(b: Record<string, unknown>): Array<{ url: unknown; contentType: unknown }> {
|
|
const numMedia = Number.parseInt((b.NumMedia as string) ?? '0', 10) || 0
|
|
const media: Array<{ url: unknown; contentType: unknown }> = []
|
|
for (let i = 0; i < numMedia; i++) {
|
|
media.push({ url: b[`MediaUrl${i}`], contentType: b[`MediaContentType${i}`] })
|
|
}
|
|
return media
|
|
}
|
|
|
|
export const twilioHandler: WebhookProviderHandler = {
|
|
verifyAuth(ctx: AuthContext) {
|
|
return verifyTwilioAuth(ctx, 'Twilio SMS')
|
|
},
|
|
|
|
/**
|
|
* Distinguish an inbound SMS from a delivery status callback so the two
|
|
* triggers don't fire on each other's deliveries when they share a URL.
|
|
* Twilio reports status `received` for inbound messages; delivery callbacks
|
|
* carry a non-`received` `MessageStatus` (queued/sent/delivered/…). Each side
|
|
* requires a positive signal, so an ambiguous payload missing both fields
|
|
* matches neither trigger rather than misrouting.
|
|
*/
|
|
matchEvent({ body, providerConfig }: EventMatchContext) {
|
|
const triggerId = providerConfig.triggerId as string | undefined
|
|
if (!triggerId) return true
|
|
const b = isRecordLike(body) ? body : {}
|
|
const messageStatus = ((b.MessageStatus as string) ?? '').toLowerCase()
|
|
const smsStatus = ((b.SmsStatus as string) ?? '').toLowerCase()
|
|
const isInbound = smsStatus === 'received' || messageStatus === 'received'
|
|
const isStatusCallback = !isInbound && (messageStatus !== '' || smsStatus !== '')
|
|
if (triggerId === 'twilio_sms_received') return isInbound
|
|
if (triggerId === 'twilio_sms_status') return isStatusCallback
|
|
return true
|
|
},
|
|
|
|
/**
|
|
* Status callbacks repeat for the same SID as the message progresses
|
|
* (sent -> delivered -> ...), so the delivery status is part of the key to
|
|
* keep each distinct callback (while still deduping Twilio's retries of
|
|
* the same status). Inbound messages fire once (SmsStatus 'received'),
|
|
* keyed by SID alone.
|
|
*/
|
|
extractIdempotencyId(body: unknown) {
|
|
if (!isRecordLike(body)) return null
|
|
const obj = body
|
|
const sid = (obj.MessageSid as string) || (obj.CallSid as string)
|
|
if (!sid) return null
|
|
const status = (
|
|
((obj.MessageStatus as string) || (obj.SmsStatus as string)) ??
|
|
''
|
|
).toLowerCase()
|
|
return status && status !== 'received' ? `${sid}:${status}` : sid
|
|
},
|
|
|
|
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
|
const b = isRecordLike(body) ? body : {}
|
|
return {
|
|
input: {
|
|
messageSid: b.MessageSid,
|
|
accountSid: b.AccountSid,
|
|
messagingServiceSid: b.MessagingServiceSid,
|
|
from: b.From,
|
|
to: b.To,
|
|
body: b.Body,
|
|
numMedia: b.NumMedia,
|
|
numSegments: b.NumSegments,
|
|
media: extractMedia(b),
|
|
smsStatus: b.SmsStatus,
|
|
messageStatus: b.MessageStatus,
|
|
errorCode: b.ErrorCode,
|
|
apiVersion: b.ApiVersion,
|
|
fromCity: b.FromCity,
|
|
fromState: b.FromState,
|
|
fromZip: b.FromZip,
|
|
fromCountry: b.FromCountry,
|
|
toCity: b.ToCity,
|
|
toState: b.ToState,
|
|
toZip: b.ToZip,
|
|
toCountry: b.ToCountry,
|
|
raw: JSON.stringify(b),
|
|
},
|
|
}
|
|
},
|
|
}
|