Files
simstudioai--sim/apps/sim/lib/webhooks/providers/twilio.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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),
},
}
},
}