@stacksjs/ts-cloud 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +1 -1
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
package/src/aws/sms.ts
ADDED
|
@@ -0,0 +1,1735 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified SMS Module
|
|
3
|
+
* Provides SMS sending and receiving with S3 storage for incoming messages
|
|
4
|
+
*
|
|
5
|
+
* Similar to the Email module, this provides:
|
|
6
|
+
* - Sending SMS via SNS
|
|
7
|
+
* - Receiving SMS stored in S3
|
|
8
|
+
* - Inbox management (list, read, delete)
|
|
9
|
+
* - Two-way messaging support
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SNSClient } from './sns'
|
|
13
|
+
import { S3Client } from './s3'
|
|
14
|
+
import { SchedulerClient } from './scheduler'
|
|
15
|
+
import { LambdaClient } from './lambda'
|
|
16
|
+
|
|
17
|
+
export interface SmsClientConfig {
|
|
18
|
+
region?: string
|
|
19
|
+
// S3 bucket for storing incoming SMS
|
|
20
|
+
inboxBucket?: string
|
|
21
|
+
inboxPrefix?: string
|
|
22
|
+
// Default sender (phone number or sender ID)
|
|
23
|
+
defaultSender?: string
|
|
24
|
+
// Lambda function ARN for scheduled SMS
|
|
25
|
+
schedulerLambdaArn?: string
|
|
26
|
+
// Role ARN for scheduler to invoke Lambda
|
|
27
|
+
schedulerRoleArn?: string
|
|
28
|
+
// S3 bucket for storing scheduled messages
|
|
29
|
+
scheduledBucket?: string
|
|
30
|
+
scheduledPrefix?: string
|
|
31
|
+
// Delivery receipts configuration
|
|
32
|
+
receiptBucket?: string
|
|
33
|
+
receiptPrefix?: string
|
|
34
|
+
// Track delivery status (requires receipts bucket)
|
|
35
|
+
trackDelivery?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SmsMessage {
|
|
39
|
+
key: string
|
|
40
|
+
from: string
|
|
41
|
+
to: string
|
|
42
|
+
body: string
|
|
43
|
+
timestamp: Date
|
|
44
|
+
messageId?: string
|
|
45
|
+
originationNumber?: string
|
|
46
|
+
destinationNumber?: string
|
|
47
|
+
// Read/unread status
|
|
48
|
+
read?: boolean
|
|
49
|
+
readAt?: Date
|
|
50
|
+
// Conversation threading
|
|
51
|
+
conversationId?: string
|
|
52
|
+
// Delivery status
|
|
53
|
+
status?: 'pending' | 'sent' | 'delivered' | 'failed'
|
|
54
|
+
deliveredAt?: Date
|
|
55
|
+
// Raw message data
|
|
56
|
+
raw?: any
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SendSmsOptions {
|
|
60
|
+
to: string
|
|
61
|
+
body: string
|
|
62
|
+
from?: string
|
|
63
|
+
// SMS type for SNS
|
|
64
|
+
type?: 'Promotional' | 'Transactional'
|
|
65
|
+
// Scheduled sending
|
|
66
|
+
scheduledAt?: Date
|
|
67
|
+
// Template ID (if using templates)
|
|
68
|
+
templateId?: string
|
|
69
|
+
// Template variables
|
|
70
|
+
templateVariables?: Record<string, string>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScheduledSms {
|
|
74
|
+
id: string
|
|
75
|
+
to: string
|
|
76
|
+
body: string
|
|
77
|
+
from?: string
|
|
78
|
+
scheduledAt: Date
|
|
79
|
+
status: 'pending' | 'sent' | 'failed' | 'cancelled'
|
|
80
|
+
createdAt: Date
|
|
81
|
+
sentAt?: Date
|
|
82
|
+
messageId?: string
|
|
83
|
+
error?: string
|
|
84
|
+
templateId?: string
|
|
85
|
+
templateVariables?: Record<string, string>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SmsTemplate {
|
|
89
|
+
id: string
|
|
90
|
+
name: string
|
|
91
|
+
body: string
|
|
92
|
+
description?: string
|
|
93
|
+
variables?: string[]
|
|
94
|
+
createdAt: Date
|
|
95
|
+
updatedAt?: Date
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface InboxOptions {
|
|
99
|
+
prefix?: string
|
|
100
|
+
maxResults?: number
|
|
101
|
+
startAfter?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DeliveryReceipt {
|
|
105
|
+
messageId: string
|
|
106
|
+
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'unknown'
|
|
107
|
+
timestamp: Date
|
|
108
|
+
to: string
|
|
109
|
+
from?: string
|
|
110
|
+
errorCode?: string
|
|
111
|
+
errorMessage?: string
|
|
112
|
+
carrierName?: string
|
|
113
|
+
// Pricing info (if available from delivery status)
|
|
114
|
+
priceInUsd?: number
|
|
115
|
+
// Message parts (for long SMS)
|
|
116
|
+
messagePartCount?: number
|
|
117
|
+
// Raw receipt data
|
|
118
|
+
raw?: any
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DeliveryReceiptWebhookConfig {
|
|
122
|
+
// SNS topic ARN to receive delivery receipts
|
|
123
|
+
snsTopicArn?: string
|
|
124
|
+
// S3 bucket for storing delivery receipts
|
|
125
|
+
receiptBucket?: string
|
|
126
|
+
receiptPrefix?: string
|
|
127
|
+
// Callback URL for HTTP webhooks
|
|
128
|
+
webhookUrl?: string
|
|
129
|
+
// Secret for webhook signature verification
|
|
130
|
+
webhookSecret?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* SMS Client with S3 inbox storage
|
|
135
|
+
*/
|
|
136
|
+
export class SmsClient {
|
|
137
|
+
private config: SmsClientConfig
|
|
138
|
+
private sns: SNSClient
|
|
139
|
+
private s3?: S3Client
|
|
140
|
+
private scheduler?: SchedulerClient
|
|
141
|
+
|
|
142
|
+
constructor(config: SmsClientConfig = {}) {
|
|
143
|
+
this.config = {
|
|
144
|
+
region: 'us-east-1',
|
|
145
|
+
inboxPrefix: 'sms/inbox/',
|
|
146
|
+
scheduledPrefix: 'sms/scheduled/',
|
|
147
|
+
receiptPrefix: 'sms/receipts/',
|
|
148
|
+
...config,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.sns = new SNSClient(this.config.region!)
|
|
152
|
+
|
|
153
|
+
if (this.config.inboxBucket || this.config.scheduledBucket) {
|
|
154
|
+
this.s3 = new S3Client(this.config.region!)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (this.config.schedulerLambdaArn) {
|
|
158
|
+
this.scheduler = new SchedulerClient(this.config.region!)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================
|
|
163
|
+
// Sending SMS
|
|
164
|
+
// ============================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Send an SMS message (optionally scheduled)
|
|
168
|
+
*/
|
|
169
|
+
async send(options: SendSmsOptions): Promise<{ messageId: string; scheduledId?: string }> {
|
|
170
|
+
const { to, from, type, scheduledAt, templateId, templateVariables } = options
|
|
171
|
+
const sender = from || this.config.defaultSender
|
|
172
|
+
|
|
173
|
+
// Resolve message body (handle templates)
|
|
174
|
+
let body = options.body
|
|
175
|
+
if (templateId) {
|
|
176
|
+
const template = await this.getTemplate(templateId)
|
|
177
|
+
if (template) {
|
|
178
|
+
body = this.applyTemplate(template.body, templateVariables || {})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// If scheduled, store for later sending
|
|
183
|
+
if (scheduledAt && scheduledAt > new Date()) {
|
|
184
|
+
const scheduledSms = await this.scheduleMessage({
|
|
185
|
+
to,
|
|
186
|
+
body,
|
|
187
|
+
from: sender,
|
|
188
|
+
scheduledAt,
|
|
189
|
+
templateId,
|
|
190
|
+
templateVariables,
|
|
191
|
+
})
|
|
192
|
+
return { messageId: '', scheduledId: scheduledSms.id }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Send via SNS
|
|
196
|
+
const result = await this.sns.publish({
|
|
197
|
+
PhoneNumber: to,
|
|
198
|
+
Message: body,
|
|
199
|
+
MessageAttributes: {
|
|
200
|
+
'AWS.SNS.SMS.SMSType': {
|
|
201
|
+
DataType: 'String',
|
|
202
|
+
StringValue: type || 'Transactional',
|
|
203
|
+
},
|
|
204
|
+
...(sender && {
|
|
205
|
+
'AWS.SNS.SMS.SenderID': {
|
|
206
|
+
DataType: 'String',
|
|
207
|
+
StringValue: sender,
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return { messageId: result.MessageId || '' }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Send a text message (alias for send)
|
|
218
|
+
*/
|
|
219
|
+
async sendText(to: string, message: string, from?: string): Promise<{ messageId: string }> {
|
|
220
|
+
return this.send({ to, body: message, from })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================
|
|
224
|
+
// Inbox Management (S3 Storage)
|
|
225
|
+
// ============================================
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get incoming SMS messages from S3 inbox
|
|
229
|
+
*/
|
|
230
|
+
async getInbox(options: InboxOptions = {}): Promise<SmsMessage[]> {
|
|
231
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
232
|
+
throw new Error('Inbox bucket not configured')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const prefix = options.prefix || this.config.inboxPrefix || 'sms/inbox/'
|
|
236
|
+
const objects = await this.s3.list({
|
|
237
|
+
bucket: this.config.inboxBucket,
|
|
238
|
+
prefix,
|
|
239
|
+
maxKeys: options.maxResults || 100,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const messages: SmsMessage[] = []
|
|
243
|
+
|
|
244
|
+
for (const obj of objects || []) {
|
|
245
|
+
if (!obj.Key) continue
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const content = await this.s3.getObject(this.config.inboxBucket, obj.Key)
|
|
249
|
+
const parsed = this.parseIncomingSms(content, obj.Key)
|
|
250
|
+
if (parsed) {
|
|
251
|
+
messages.push(parsed)
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.error(`Failed to read SMS ${obj.Key}:`, err)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort by timestamp descending (newest first)
|
|
259
|
+
return messages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get a specific SMS message by key
|
|
264
|
+
*/
|
|
265
|
+
async getMessage(key: string): Promise<SmsMessage | null> {
|
|
266
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
267
|
+
throw new Error('Inbox bucket not configured')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const content = await this.s3.getObject(this.config.inboxBucket, key)
|
|
272
|
+
return this.parseIncomingSms(content, key)
|
|
273
|
+
} catch (err) {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Delete an SMS message from inbox
|
|
280
|
+
*/
|
|
281
|
+
async deleteMessage(key: string): Promise<void> {
|
|
282
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
283
|
+
throw new Error('Inbox bucket not configured')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await this.s3.deleteObject(this.config.inboxBucket, key)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Move an SMS to a different folder (e.g., archive)
|
|
291
|
+
*/
|
|
292
|
+
async moveMessage(key: string, destinationPrefix: string): Promise<string> {
|
|
293
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
294
|
+
throw new Error('Inbox bucket not configured')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const content = await this.s3.getObject(this.config.inboxBucket, key)
|
|
298
|
+
const filename = key.split('/').pop() || `${Date.now()}.json`
|
|
299
|
+
const newKey = `${destinationPrefix}${filename}`
|
|
300
|
+
|
|
301
|
+
await this.s3.putObject({
|
|
302
|
+
bucket: this.config.inboxBucket,
|
|
303
|
+
key: newKey,
|
|
304
|
+
body: content,
|
|
305
|
+
contentType: 'application/json',
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await this.s3.deleteObject(this.config.inboxBucket, key)
|
|
309
|
+
|
|
310
|
+
return newKey
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Archive an SMS message
|
|
315
|
+
*/
|
|
316
|
+
async archiveMessage(key: string): Promise<string> {
|
|
317
|
+
return this.moveMessage(key, 'sms/archive/')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Mark a message as read
|
|
322
|
+
*/
|
|
323
|
+
async markAsRead(key: string): Promise<void> {
|
|
324
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
325
|
+
throw new Error('Inbox bucket not configured')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const content = await this.s3.getObject(this.config.inboxBucket, key)
|
|
329
|
+
const data = JSON.parse(content)
|
|
330
|
+
data.read = true
|
|
331
|
+
data.readAt = new Date().toISOString()
|
|
332
|
+
|
|
333
|
+
await this.s3.putObject({
|
|
334
|
+
bucket: this.config.inboxBucket,
|
|
335
|
+
key,
|
|
336
|
+
body: JSON.stringify(data, null, 2),
|
|
337
|
+
contentType: 'application/json',
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Mark a message as unread
|
|
343
|
+
*/
|
|
344
|
+
async markAsUnread(key: string): Promise<void> {
|
|
345
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
346
|
+
throw new Error('Inbox bucket not configured')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const content = await this.s3.getObject(this.config.inboxBucket, key)
|
|
350
|
+
const data = JSON.parse(content)
|
|
351
|
+
data.read = false
|
|
352
|
+
delete data.readAt
|
|
353
|
+
|
|
354
|
+
await this.s3.putObject({
|
|
355
|
+
bucket: this.config.inboxBucket,
|
|
356
|
+
key,
|
|
357
|
+
body: JSON.stringify(data, null, 2),
|
|
358
|
+
contentType: 'application/json',
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Get unread message count
|
|
364
|
+
*/
|
|
365
|
+
async getUnreadCount(): Promise<number> {
|
|
366
|
+
const messages = await this.getInbox({ maxResults: 1000 })
|
|
367
|
+
return messages.filter(m => !m.read).length
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Batch mark messages as read
|
|
372
|
+
*/
|
|
373
|
+
async markManyAsRead(keys: string[]): Promise<void> {
|
|
374
|
+
await Promise.all(keys.map(key => this.markAsRead(key)))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Batch delete messages
|
|
379
|
+
*/
|
|
380
|
+
async deleteMany(keys: string[]): Promise<void> {
|
|
381
|
+
await Promise.all(keys.map(key => this.deleteMessage(key)))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Get inbox count
|
|
386
|
+
*/
|
|
387
|
+
async getInboxCount(): Promise<number> {
|
|
388
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
389
|
+
throw new Error('Inbox bucket not configured')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const objects = await this.s3.list({
|
|
393
|
+
bucket: this.config.inboxBucket,
|
|
394
|
+
prefix: this.config.inboxPrefix || 'sms/inbox/',
|
|
395
|
+
maxKeys: 1000,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
return objects?.length || 0
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============================================
|
|
402
|
+
// Conversation Threading
|
|
403
|
+
// ============================================
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Get conversation ID for a phone number pair
|
|
407
|
+
* Normalizes phone numbers and creates a consistent ID
|
|
408
|
+
*/
|
|
409
|
+
getConversationId(phone1: string, phone2: string): string {
|
|
410
|
+
const normalized = [normalizePhoneNumber(phone1), normalizePhoneNumber(phone2)].sort()
|
|
411
|
+
return `${normalized[0]}_${normalized[1]}`
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get all messages in a conversation with a specific phone number
|
|
416
|
+
*/
|
|
417
|
+
async getConversation(phoneNumber: string, myNumber?: string): Promise<SmsMessage[]> {
|
|
418
|
+
const messages = await this.getInbox({ maxResults: 1000 })
|
|
419
|
+
const normalizedTarget = normalizePhoneNumber(phoneNumber)
|
|
420
|
+
|
|
421
|
+
return messages.filter(m => {
|
|
422
|
+
const from = normalizePhoneNumber(m.from)
|
|
423
|
+
const to = normalizePhoneNumber(m.to)
|
|
424
|
+
return from === normalizedTarget || to === normalizedTarget
|
|
425
|
+
}).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get unique conversations (grouped by contact)
|
|
430
|
+
*/
|
|
431
|
+
async getConversations(): Promise<Array<{
|
|
432
|
+
phoneNumber: string
|
|
433
|
+
lastMessage: SmsMessage
|
|
434
|
+
messageCount: number
|
|
435
|
+
unreadCount: number
|
|
436
|
+
}>> {
|
|
437
|
+
const messages = await this.getInbox({ maxResults: 1000 })
|
|
438
|
+
const conversations = new Map<string, {
|
|
439
|
+
phoneNumber: string
|
|
440
|
+
messages: SmsMessage[]
|
|
441
|
+
}>()
|
|
442
|
+
|
|
443
|
+
for (const msg of messages) {
|
|
444
|
+
// Use the "other" phone number as the conversation key
|
|
445
|
+
const otherNumber = normalizePhoneNumber(msg.from) // Incoming messages
|
|
446
|
+
if (!conversations.has(otherNumber)) {
|
|
447
|
+
conversations.set(otherNumber, { phoneNumber: otherNumber, messages: [] })
|
|
448
|
+
}
|
|
449
|
+
conversations.get(otherNumber)!.messages.push(msg)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return Array.from(conversations.values()).map(conv => ({
|
|
453
|
+
phoneNumber: conv.phoneNumber,
|
|
454
|
+
lastMessage: conv.messages[0], // Already sorted newest first
|
|
455
|
+
messageCount: conv.messages.length,
|
|
456
|
+
unreadCount: conv.messages.filter(m => !m.read).length,
|
|
457
|
+
})).sort((a, b) => b.lastMessage.timestamp.getTime() - a.lastMessage.timestamp.getTime())
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================
|
|
461
|
+
// Two-Way Messaging Support
|
|
462
|
+
// ============================================
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Store an incoming SMS to S3
|
|
466
|
+
* This is typically called from a Lambda handler that receives SNS webhooks
|
|
467
|
+
*/
|
|
468
|
+
async storeIncomingSms(message: {
|
|
469
|
+
from: string
|
|
470
|
+
to: string
|
|
471
|
+
body: string
|
|
472
|
+
messageId?: string
|
|
473
|
+
timestamp?: Date
|
|
474
|
+
raw?: any
|
|
475
|
+
}): Promise<string> {
|
|
476
|
+
if (!this.s3 || !this.config.inboxBucket) {
|
|
477
|
+
throw new Error('Inbox bucket not configured')
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const timestamp = message.timestamp || new Date()
|
|
481
|
+
const messageId = message.messageId || `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
482
|
+
const key = `${this.config.inboxPrefix}${timestamp.toISOString().split('T')[0]}/${messageId}.json`
|
|
483
|
+
|
|
484
|
+
const smsData = {
|
|
485
|
+
from: message.from,
|
|
486
|
+
to: message.to,
|
|
487
|
+
body: message.body,
|
|
488
|
+
messageId,
|
|
489
|
+
timestamp: timestamp.toISOString(),
|
|
490
|
+
raw: message.raw,
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
await this.s3.putObject({
|
|
494
|
+
bucket: this.config.inboxBucket,
|
|
495
|
+
key,
|
|
496
|
+
body: JSON.stringify(smsData, null, 2),
|
|
497
|
+
contentType: 'application/json',
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
return key
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ============================================
|
|
504
|
+
// Opt-Out Management
|
|
505
|
+
// ============================================
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Check if a phone number is opted out
|
|
509
|
+
*/
|
|
510
|
+
async isOptedOut(phoneNumber: string): Promise<boolean> {
|
|
511
|
+
return this.sns.checkIfPhoneNumberIsOptedOut(phoneNumber)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Get list of opted-out phone numbers
|
|
516
|
+
*/
|
|
517
|
+
async getOptedOutNumbers(): Promise<string[]> {
|
|
518
|
+
const result = await this.sns.listPhoneNumbersOptedOut()
|
|
519
|
+
return result.phoneNumbers || []
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Opt a phone number back in (requires user consent)
|
|
524
|
+
*/
|
|
525
|
+
async optIn(phoneNumber: string): Promise<void> {
|
|
526
|
+
await this.sns.optInPhoneNumber(phoneNumber)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ============================================
|
|
530
|
+
// Sandbox Management (SNS)
|
|
531
|
+
// ============================================
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Check if account is in SMS sandbox
|
|
535
|
+
*/
|
|
536
|
+
async isInSandbox(): Promise<boolean> {
|
|
537
|
+
const status = await this.sns.getSMSSandboxAccountStatus()
|
|
538
|
+
return status.IsInSandbox
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Add a phone number to SMS sandbox for testing
|
|
543
|
+
*/
|
|
544
|
+
async addSandboxNumber(phoneNumber: string): Promise<void> {
|
|
545
|
+
await this.sns.createSMSSandboxPhoneNumber(phoneNumber)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Verify a sandbox phone number with OTP
|
|
550
|
+
*/
|
|
551
|
+
async verifySandboxNumber(phoneNumber: string, otp: string): Promise<void> {
|
|
552
|
+
await this.sns.verifySMSSandboxPhoneNumber(phoneNumber, otp)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* List sandbox phone numbers
|
|
557
|
+
*/
|
|
558
|
+
async listSandboxNumbers(): Promise<Array<{ PhoneNumber: string; Status: string }>> {
|
|
559
|
+
const result = await this.sns.listSMSSandboxPhoneNumbers()
|
|
560
|
+
return (result?.PhoneNumbers || []) as { PhoneNumber: string; Status: string }[]
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ============================================
|
|
564
|
+
// Scheduled SMS
|
|
565
|
+
// ============================================
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Schedule an SMS message for later delivery
|
|
569
|
+
*/
|
|
570
|
+
async scheduleMessage(options: {
|
|
571
|
+
to: string
|
|
572
|
+
body: string
|
|
573
|
+
from?: string
|
|
574
|
+
scheduledAt: Date
|
|
575
|
+
templateId?: string
|
|
576
|
+
templateVariables?: Record<string, string>
|
|
577
|
+
}): Promise<ScheduledSms> {
|
|
578
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
579
|
+
if (!this.s3 || !bucket) {
|
|
580
|
+
throw new Error('Scheduled bucket not configured')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
584
|
+
const key = `${this.config.scheduledPrefix}${id}.json`
|
|
585
|
+
|
|
586
|
+
const scheduledSms: ScheduledSms = {
|
|
587
|
+
id,
|
|
588
|
+
to: options.to,
|
|
589
|
+
body: options.body,
|
|
590
|
+
from: options.from,
|
|
591
|
+
scheduledAt: options.scheduledAt,
|
|
592
|
+
status: 'pending',
|
|
593
|
+
createdAt: new Date(),
|
|
594
|
+
templateId: options.templateId,
|
|
595
|
+
templateVariables: options.templateVariables,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
await this.s3.putObject({
|
|
599
|
+
bucket,
|
|
600
|
+
key,
|
|
601
|
+
body: JSON.stringify(scheduledSms, null, 2),
|
|
602
|
+
contentType: 'application/json',
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Create EventBridge schedule if scheduler is configured
|
|
606
|
+
if (this.scheduler && this.config.schedulerLambdaArn && this.config.schedulerRoleArn) {
|
|
607
|
+
const scheduleExpression = `at(${options.scheduledAt.toISOString().replace(/\.\d{3}Z$/, '')})`
|
|
608
|
+
await this.scheduler.createLambdaSchedule({
|
|
609
|
+
name: `sms-${id}`,
|
|
610
|
+
scheduleExpression,
|
|
611
|
+
functionArn: this.config.schedulerLambdaArn,
|
|
612
|
+
input: JSON.stringify({ scheduledSmsId: id, bucket, key }),
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return scheduledSms
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Get all scheduled SMS messages
|
|
621
|
+
*/
|
|
622
|
+
async getScheduledMessages(): Promise<ScheduledSms[]> {
|
|
623
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
624
|
+
if (!this.s3 || !bucket) {
|
|
625
|
+
throw new Error('Scheduled bucket not configured')
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const objects = await this.s3.list({
|
|
629
|
+
bucket,
|
|
630
|
+
prefix: this.config.scheduledPrefix || 'sms/scheduled/',
|
|
631
|
+
maxKeys: 1000,
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const messages: ScheduledSms[] = []
|
|
635
|
+
for (const obj of objects || []) {
|
|
636
|
+
if (!obj.Key) continue
|
|
637
|
+
try {
|
|
638
|
+
const content = await this.s3.getObject(bucket, obj.Key)
|
|
639
|
+
const sms = JSON.parse(content) as ScheduledSms
|
|
640
|
+
sms.scheduledAt = new Date(sms.scheduledAt)
|
|
641
|
+
sms.createdAt = new Date(sms.createdAt)
|
|
642
|
+
if (sms.sentAt) sms.sentAt = new Date(sms.sentAt)
|
|
643
|
+
messages.push(sms)
|
|
644
|
+
} catch {
|
|
645
|
+
// Skip invalid entries
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return messages.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Get a scheduled SMS by ID
|
|
654
|
+
*/
|
|
655
|
+
async getScheduledMessage(id: string): Promise<ScheduledSms | null> {
|
|
656
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
657
|
+
if (!this.s3 || !bucket) {
|
|
658
|
+
throw new Error('Scheduled bucket not configured')
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const key = `${this.config.scheduledPrefix}${id}.json`
|
|
662
|
+
try {
|
|
663
|
+
const content = await this.s3.getObject(bucket, key)
|
|
664
|
+
const sms = JSON.parse(content) as ScheduledSms
|
|
665
|
+
sms.scheduledAt = new Date(sms.scheduledAt)
|
|
666
|
+
sms.createdAt = new Date(sms.createdAt)
|
|
667
|
+
if (sms.sentAt) sms.sentAt = new Date(sms.sentAt)
|
|
668
|
+
return sms
|
|
669
|
+
} catch {
|
|
670
|
+
return null
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Cancel a scheduled SMS
|
|
676
|
+
*/
|
|
677
|
+
async cancelScheduledMessage(id: string): Promise<void> {
|
|
678
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
679
|
+
if (!this.s3 || !bucket) {
|
|
680
|
+
throw new Error('Scheduled bucket not configured')
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const key = `${this.config.scheduledPrefix}${id}.json`
|
|
684
|
+
const sms = await this.getScheduledMessage(id)
|
|
685
|
+
if (!sms) throw new Error(`Scheduled SMS ${id} not found`)
|
|
686
|
+
|
|
687
|
+
sms.status = 'cancelled'
|
|
688
|
+
await this.s3.putObject({
|
|
689
|
+
bucket,
|
|
690
|
+
key,
|
|
691
|
+
body: JSON.stringify(sms, null, 2),
|
|
692
|
+
contentType: 'application/json',
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// Delete EventBridge schedule if scheduler is configured
|
|
696
|
+
if (this.scheduler) {
|
|
697
|
+
try {
|
|
698
|
+
await this.scheduler.deleteRule(`sms-${id}`, true)
|
|
699
|
+
} catch {
|
|
700
|
+
// Rule may not exist
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Send a scheduled SMS immediately (called by Lambda handler)
|
|
707
|
+
*/
|
|
708
|
+
async sendScheduledMessage(id: string): Promise<{ messageId: string }> {
|
|
709
|
+
const sms = await this.getScheduledMessage(id)
|
|
710
|
+
if (!sms) throw new Error(`Scheduled SMS ${id} not found`)
|
|
711
|
+
if (sms.status !== 'pending') {
|
|
712
|
+
throw new Error(`Scheduled SMS ${id} is not pending (status: ${sms.status})`)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
const result = await this.send({
|
|
717
|
+
to: sms.to,
|
|
718
|
+
body: sms.body,
|
|
719
|
+
from: sms.from,
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
// Update status
|
|
723
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket!
|
|
724
|
+
const key = `${this.config.scheduledPrefix}${id}.json`
|
|
725
|
+
sms.status = 'sent'
|
|
726
|
+
sms.sentAt = new Date()
|
|
727
|
+
sms.messageId = result.messageId
|
|
728
|
+
|
|
729
|
+
await this.s3!.putObject({
|
|
730
|
+
bucket,
|
|
731
|
+
key,
|
|
732
|
+
body: JSON.stringify(sms, null, 2),
|
|
733
|
+
contentType: 'application/json',
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
return result
|
|
737
|
+
} catch (err: any) {
|
|
738
|
+
// Update with error
|
|
739
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket!
|
|
740
|
+
const key = `${this.config.scheduledPrefix}${id}.json`
|
|
741
|
+
sms.status = 'failed'
|
|
742
|
+
sms.error = err.message
|
|
743
|
+
|
|
744
|
+
await this.s3!.putObject({
|
|
745
|
+
bucket,
|
|
746
|
+
key,
|
|
747
|
+
body: JSON.stringify(sms, null, 2),
|
|
748
|
+
contentType: 'application/json',
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
throw err
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Schedule SMS to send at a specific time (convenience method)
|
|
757
|
+
*/
|
|
758
|
+
async sendAt(to: string, body: string, scheduledAt: Date, from?: string): Promise<ScheduledSms> {
|
|
759
|
+
return this.scheduleMessage({ to, body, scheduledAt, from })
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Schedule SMS to send after a delay (convenience method)
|
|
764
|
+
*/
|
|
765
|
+
async sendAfter(
|
|
766
|
+
to: string,
|
|
767
|
+
body: string,
|
|
768
|
+
delayMinutes: number,
|
|
769
|
+
from?: string,
|
|
770
|
+
): Promise<ScheduledSms> {
|
|
771
|
+
const scheduledAt = new Date(Date.now() + delayMinutes * 60 * 1000)
|
|
772
|
+
return this.scheduleMessage({ to, body, scheduledAt, from })
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ============================================
|
|
776
|
+
// SMS Templates
|
|
777
|
+
// ============================================
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Create an SMS template
|
|
781
|
+
*/
|
|
782
|
+
async createTemplate(template: {
|
|
783
|
+
name: string
|
|
784
|
+
body: string
|
|
785
|
+
description?: string
|
|
786
|
+
}): Promise<SmsTemplate> {
|
|
787
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
788
|
+
if (!this.s3 || !bucket) {
|
|
789
|
+
throw new Error('Templates bucket not configured')
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
793
|
+
const key = `sms/templates/${id}.json`
|
|
794
|
+
|
|
795
|
+
// Extract variables from template (e.g., {{name}}, {{code}})
|
|
796
|
+
const variableMatches = template.body.match(/\{\{(\w+)\}\}/g) || []
|
|
797
|
+
const variables = variableMatches.map(m => m.replace(/\{\{|\}\}/g, ''))
|
|
798
|
+
|
|
799
|
+
const smsTemplate: SmsTemplate = {
|
|
800
|
+
id,
|
|
801
|
+
name: template.name,
|
|
802
|
+
body: template.body,
|
|
803
|
+
description: template.description,
|
|
804
|
+
variables: [...new Set(variables)],
|
|
805
|
+
createdAt: new Date(),
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
await this.s3.putObject({
|
|
809
|
+
bucket,
|
|
810
|
+
key,
|
|
811
|
+
body: JSON.stringify(smsTemplate, null, 2),
|
|
812
|
+
contentType: 'application/json',
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
return smsTemplate
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Get all SMS templates
|
|
820
|
+
*/
|
|
821
|
+
async getTemplates(): Promise<SmsTemplate[]> {
|
|
822
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
823
|
+
if (!this.s3 || !bucket) {
|
|
824
|
+
throw new Error('Templates bucket not configured')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const objects = await this.s3.list({
|
|
828
|
+
bucket,
|
|
829
|
+
prefix: 'sms/templates/',
|
|
830
|
+
maxKeys: 1000,
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
const templates: SmsTemplate[] = []
|
|
834
|
+
for (const obj of objects || []) {
|
|
835
|
+
if (!obj.Key) continue
|
|
836
|
+
try {
|
|
837
|
+
const content = await this.s3.getObject(bucket, obj.Key)
|
|
838
|
+
const template = JSON.parse(content) as SmsTemplate
|
|
839
|
+
template.createdAt = new Date(template.createdAt)
|
|
840
|
+
if (template.updatedAt) template.updatedAt = new Date(template.updatedAt)
|
|
841
|
+
templates.push(template)
|
|
842
|
+
} catch {
|
|
843
|
+
// Skip invalid entries
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return templates.sort((a, b) => a.name.localeCompare(b.name))
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Get a template by ID
|
|
852
|
+
*/
|
|
853
|
+
async getTemplate(id: string): Promise<SmsTemplate | null> {
|
|
854
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
855
|
+
if (!this.s3 || !bucket) {
|
|
856
|
+
return null
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const key = `sms/templates/${id}.json`
|
|
860
|
+
try {
|
|
861
|
+
const content = await this.s3.getObject(bucket, key)
|
|
862
|
+
const template = JSON.parse(content) as SmsTemplate
|
|
863
|
+
template.createdAt = new Date(template.createdAt)
|
|
864
|
+
if (template.updatedAt) template.updatedAt = new Date(template.updatedAt)
|
|
865
|
+
return template
|
|
866
|
+
} catch {
|
|
867
|
+
return null
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Get a template by name
|
|
873
|
+
*/
|
|
874
|
+
async getTemplateByName(name: string): Promise<SmsTemplate | null> {
|
|
875
|
+
const templates = await this.getTemplates()
|
|
876
|
+
return templates.find(t => t.name === name) || null
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Update a template
|
|
881
|
+
*/
|
|
882
|
+
async updateTemplate(
|
|
883
|
+
id: string,
|
|
884
|
+
updates: { name?: string; body?: string; description?: string },
|
|
885
|
+
): Promise<SmsTemplate> {
|
|
886
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
887
|
+
if (!this.s3 || !bucket) {
|
|
888
|
+
throw new Error('Templates bucket not configured')
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const template = await this.getTemplate(id)
|
|
892
|
+
if (!template) throw new Error(`Template ${id} not found`)
|
|
893
|
+
|
|
894
|
+
if (updates.name) template.name = updates.name
|
|
895
|
+
if (updates.body) {
|
|
896
|
+
template.body = updates.body
|
|
897
|
+
// Re-extract variables
|
|
898
|
+
const variableMatches = updates.body.match(/\{\{(\w+)\}\}/g) || []
|
|
899
|
+
template.variables = [...new Set(variableMatches.map(m => m.replace(/\{\{|\}\}/g, '')))]
|
|
900
|
+
}
|
|
901
|
+
if (updates.description !== undefined) template.description = updates.description
|
|
902
|
+
template.updatedAt = new Date()
|
|
903
|
+
|
|
904
|
+
const key = `sms/templates/${id}.json`
|
|
905
|
+
await this.s3.putObject({
|
|
906
|
+
bucket,
|
|
907
|
+
key,
|
|
908
|
+
body: JSON.stringify(template, null, 2),
|
|
909
|
+
contentType: 'application/json',
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
return template
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Delete a template
|
|
917
|
+
*/
|
|
918
|
+
async deleteTemplate(id: string): Promise<void> {
|
|
919
|
+
const bucket = this.config.scheduledBucket || this.config.inboxBucket
|
|
920
|
+
if (!this.s3 || !bucket) {
|
|
921
|
+
throw new Error('Templates bucket not configured')
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
await this.s3.deleteObject(bucket, `sms/templates/${id}.json`)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Send using a template
|
|
929
|
+
*/
|
|
930
|
+
async sendTemplate(
|
|
931
|
+
to: string,
|
|
932
|
+
templateId: string,
|
|
933
|
+
variables: Record<string, string>,
|
|
934
|
+
from?: string,
|
|
935
|
+
): Promise<{ messageId: string }> {
|
|
936
|
+
const template = await this.getTemplate(templateId)
|
|
937
|
+
if (!template) throw new Error(`Template ${templateId} not found`)
|
|
938
|
+
|
|
939
|
+
const body = this.applyTemplate(template.body, variables)
|
|
940
|
+
return this.send({ to, body, from })
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Apply variables to a template string
|
|
945
|
+
*/
|
|
946
|
+
private applyTemplate(template: string, variables: Record<string, string>): string {
|
|
947
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
948
|
+
return variables[key] !== undefined ? variables[key] : match
|
|
949
|
+
})
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ============================================
|
|
953
|
+
// Delivery Receipts / Webhooks
|
|
954
|
+
// ============================================
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Store a delivery receipt
|
|
958
|
+
* Called by the Lambda handler when a delivery status notification is received
|
|
959
|
+
*/
|
|
960
|
+
async storeDeliveryReceipt(receipt: {
|
|
961
|
+
messageId: string
|
|
962
|
+
status: DeliveryReceipt['status']
|
|
963
|
+
to: string
|
|
964
|
+
from?: string
|
|
965
|
+
errorCode?: string
|
|
966
|
+
errorMessage?: string
|
|
967
|
+
carrierName?: string
|
|
968
|
+
priceInUsd?: number
|
|
969
|
+
messagePartCount?: number
|
|
970
|
+
timestamp?: Date
|
|
971
|
+
raw?: any
|
|
972
|
+
}): Promise<string> {
|
|
973
|
+
const bucket = this.config.receiptBucket || this.config.inboxBucket
|
|
974
|
+
if (!this.s3 || !bucket) {
|
|
975
|
+
throw new Error('Receipt bucket not configured')
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const timestamp = receipt.timestamp || new Date()
|
|
979
|
+
const key = `${this.config.receiptPrefix}${timestamp.toISOString().split('T')[0]}/${receipt.messageId}.json`
|
|
980
|
+
|
|
981
|
+
const deliveryReceipt: DeliveryReceipt = {
|
|
982
|
+
messageId: receipt.messageId,
|
|
983
|
+
status: receipt.status,
|
|
984
|
+
timestamp,
|
|
985
|
+
to: receipt.to,
|
|
986
|
+
from: receipt.from,
|
|
987
|
+
errorCode: receipt.errorCode,
|
|
988
|
+
errorMessage: receipt.errorMessage,
|
|
989
|
+
carrierName: receipt.carrierName,
|
|
990
|
+
priceInUsd: receipt.priceInUsd,
|
|
991
|
+
messagePartCount: receipt.messagePartCount,
|
|
992
|
+
raw: receipt.raw,
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
await this.s3.putObject({
|
|
996
|
+
bucket,
|
|
997
|
+
key,
|
|
998
|
+
body: JSON.stringify(deliveryReceipt, null, 2),
|
|
999
|
+
contentType: 'application/json',
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
return key
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Get delivery receipt for a message
|
|
1007
|
+
*/
|
|
1008
|
+
async getDeliveryReceipt(messageId: string): Promise<DeliveryReceipt | null> {
|
|
1009
|
+
const bucket = this.config.receiptBucket || this.config.inboxBucket
|
|
1010
|
+
if (!this.s3 || !bucket) {
|
|
1011
|
+
return null
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Search for the receipt in recent days
|
|
1015
|
+
const today = new Date()
|
|
1016
|
+
for (let i = 0; i < 7; i++) {
|
|
1017
|
+
const date = new Date(today)
|
|
1018
|
+
date.setDate(date.getDate() - i)
|
|
1019
|
+
const dateStr = date.toISOString().split('T')[0]
|
|
1020
|
+
const key = `${this.config.receiptPrefix}${dateStr}/${messageId}.json`
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
const content = await this.s3.getObject(bucket, key)
|
|
1024
|
+
const receipt = JSON.parse(content) as DeliveryReceipt
|
|
1025
|
+
receipt.timestamp = new Date(receipt.timestamp)
|
|
1026
|
+
return receipt
|
|
1027
|
+
} catch {
|
|
1028
|
+
// Try next day
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return null
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Get all delivery receipts (recent)
|
|
1037
|
+
*/
|
|
1038
|
+
async getDeliveryReceipts(options: {
|
|
1039
|
+
maxResults?: number
|
|
1040
|
+
status?: DeliveryReceipt['status']
|
|
1041
|
+
} = {}): Promise<DeliveryReceipt[]> {
|
|
1042
|
+
const bucket = this.config.receiptBucket || this.config.inboxBucket
|
|
1043
|
+
if (!this.s3 || !bucket) {
|
|
1044
|
+
throw new Error('Receipt bucket not configured')
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const objects = await this.s3.list({
|
|
1048
|
+
bucket,
|
|
1049
|
+
prefix: this.config.receiptPrefix,
|
|
1050
|
+
maxKeys: options.maxResults || 100,
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
const receipts: DeliveryReceipt[] = []
|
|
1054
|
+
for (const obj of objects || []) {
|
|
1055
|
+
if (!obj.Key || !obj.Key.endsWith('.json')) continue
|
|
1056
|
+
try {
|
|
1057
|
+
const content = await this.s3.getObject(bucket, obj.Key)
|
|
1058
|
+
const receipt = JSON.parse(content) as DeliveryReceipt
|
|
1059
|
+
receipt.timestamp = new Date(receipt.timestamp)
|
|
1060
|
+
|
|
1061
|
+
// Filter by status if specified
|
|
1062
|
+
if (options.status && receipt.status !== options.status) continue
|
|
1063
|
+
|
|
1064
|
+
receipts.push(receipt)
|
|
1065
|
+
} catch {
|
|
1066
|
+
// Skip invalid entries
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return receipts.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Get delivery status for a message
|
|
1075
|
+
*/
|
|
1076
|
+
async getDeliveryStatus(messageId: string): Promise<DeliveryReceipt['status']> {
|
|
1077
|
+
const receipt = await this.getDeliveryReceipt(messageId)
|
|
1078
|
+
return receipt?.status || 'unknown'
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Wait for delivery confirmation (polling)
|
|
1083
|
+
*/
|
|
1084
|
+
async waitForDelivery(
|
|
1085
|
+
messageId: string,
|
|
1086
|
+
options: {
|
|
1087
|
+
timeoutMs?: number
|
|
1088
|
+
pollIntervalMs?: number
|
|
1089
|
+
} = {},
|
|
1090
|
+
): Promise<DeliveryReceipt | null> {
|
|
1091
|
+
const timeout = options.timeoutMs || 30000 // 30 seconds default
|
|
1092
|
+
const pollInterval = options.pollIntervalMs || 1000 // 1 second default
|
|
1093
|
+
const startTime = Date.now()
|
|
1094
|
+
|
|
1095
|
+
while (Date.now() - startTime < timeout) {
|
|
1096
|
+
const receipt = await this.getDeliveryReceipt(messageId)
|
|
1097
|
+
if (receipt && (receipt.status === 'delivered' || receipt.status === 'failed')) {
|
|
1098
|
+
return receipt
|
|
1099
|
+
}
|
|
1100
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return await this.getDeliveryReceipt(messageId)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Get failed message receipts
|
|
1108
|
+
*/
|
|
1109
|
+
async getFailedMessages(maxResults: number = 100): Promise<DeliveryReceipt[]> {
|
|
1110
|
+
return this.getDeliveryReceipts({ maxResults, status: 'failed' })
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Get delivery statistics
|
|
1115
|
+
*/
|
|
1116
|
+
async getDeliveryStats(options: {
|
|
1117
|
+
since?: Date
|
|
1118
|
+
maxMessages?: number
|
|
1119
|
+
} = {}): Promise<{
|
|
1120
|
+
total: number
|
|
1121
|
+
delivered: number
|
|
1122
|
+
failed: number
|
|
1123
|
+
pending: number
|
|
1124
|
+
deliveryRate: number
|
|
1125
|
+
averagePriceUsd: number
|
|
1126
|
+
}> {
|
|
1127
|
+
const receipts = await this.getDeliveryReceipts({ maxResults: options.maxMessages || 1000 })
|
|
1128
|
+
|
|
1129
|
+
const since = options.since || new Date(0)
|
|
1130
|
+
const filtered = receipts.filter(r => r.timestamp >= since)
|
|
1131
|
+
|
|
1132
|
+
const delivered = filtered.filter(r => r.status === 'delivered').length
|
|
1133
|
+
const failed = filtered.filter(r => r.status === 'failed').length
|
|
1134
|
+
const pending = filtered.filter(r => r.status === 'pending' || r.status === 'sent').length
|
|
1135
|
+
|
|
1136
|
+
const pricesWithValue = filtered.filter(r => r.priceInUsd !== undefined).map(r => r.priceInUsd!)
|
|
1137
|
+
const averagePriceUsd = pricesWithValue.length > 0
|
|
1138
|
+
? pricesWithValue.reduce((a, b) => a + b, 0) / pricesWithValue.length
|
|
1139
|
+
: 0
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
total: filtered.length,
|
|
1143
|
+
delivered,
|
|
1144
|
+
failed,
|
|
1145
|
+
pending,
|
|
1146
|
+
deliveryRate: filtered.length > 0 ? delivered / filtered.length : 0,
|
|
1147
|
+
averagePriceUsd,
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Send SMS and track delivery
|
|
1153
|
+
* Combines send() with delivery tracking
|
|
1154
|
+
*/
|
|
1155
|
+
async sendAndTrack(options: SendSmsOptions): Promise<{
|
|
1156
|
+
messageId: string
|
|
1157
|
+
trackDelivery: () => Promise<DeliveryReceipt | null>
|
|
1158
|
+
waitForDelivery: (timeoutMs?: number) => Promise<DeliveryReceipt | null>
|
|
1159
|
+
}> {
|
|
1160
|
+
const result = await this.send(options)
|
|
1161
|
+
|
|
1162
|
+
return {
|
|
1163
|
+
messageId: result.messageId,
|
|
1164
|
+
trackDelivery: () => this.getDeliveryReceipt(result.messageId),
|
|
1165
|
+
waitForDelivery: (timeoutMs?: number) =>
|
|
1166
|
+
this.waitForDelivery(result.messageId, { timeoutMs }),
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ============================================
|
|
1171
|
+
// Private Helpers
|
|
1172
|
+
// ============================================
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Parse incoming SMS from various formats (SNS notification, raw JSON)
|
|
1176
|
+
*/
|
|
1177
|
+
private parseIncomingSms(content: string, key: string): SmsMessage | null {
|
|
1178
|
+
try {
|
|
1179
|
+
const data = JSON.parse(content)
|
|
1180
|
+
|
|
1181
|
+
// If it's an SNS notification wrapper
|
|
1182
|
+
if (data.Type === 'Notification' && data.Message) {
|
|
1183
|
+
const innerMessage = JSON.parse(data.Message)
|
|
1184
|
+
return this.extractSmsFields(innerMessage, key, data.Timestamp)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Direct SMS data format
|
|
1188
|
+
return this.extractSmsFields(data, key)
|
|
1189
|
+
} catch {
|
|
1190
|
+
// Not JSON, might be raw text
|
|
1191
|
+
return {
|
|
1192
|
+
key,
|
|
1193
|
+
from: 'unknown',
|
|
1194
|
+
to: 'unknown',
|
|
1195
|
+
body: content,
|
|
1196
|
+
timestamp: new Date(),
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Extract SMS fields from various data formats
|
|
1203
|
+
*/
|
|
1204
|
+
private extractSmsFields(data: any, key: string, timestamp?: string): SmsMessage {
|
|
1205
|
+
return {
|
|
1206
|
+
key,
|
|
1207
|
+
from: data.from || data.originationNumber || data.OriginationNumber || data.sourceNumber || 'unknown',
|
|
1208
|
+
to: data.to || data.destinationNumber || data.DestinationNumber || data.destinationAddress || 'unknown',
|
|
1209
|
+
body: data.body || data.message || data.messageBody || data.Message || data.text || '',
|
|
1210
|
+
timestamp: new Date(timestamp || data.timestamp || data.Timestamp || Date.now()),
|
|
1211
|
+
messageId: data.messageId || data.MessageId || data.inboundMessageId,
|
|
1212
|
+
originationNumber: data.originationNumber || data.OriginationNumber,
|
|
1213
|
+
destinationNumber: data.destinationNumber || data.DestinationNumber,
|
|
1214
|
+
raw: data,
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ============================================
|
|
1220
|
+
// Lambda Handler for Incoming SMS
|
|
1221
|
+
// ============================================
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Create a Lambda handler for processing incoming SMS from SNS
|
|
1225
|
+
* Use this with an SNS topic that receives two-way SMS messages
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
* ```typescript
|
|
1229
|
+
* // lambda.ts
|
|
1230
|
+
* import { createSmsInboxHandler } from 'ts-cloud/aws/sms'
|
|
1231
|
+
*
|
|
1232
|
+
* export const handler = createSmsInboxHandler({
|
|
1233
|
+
* bucket: 'my-sms-bucket',
|
|
1234
|
+
* prefix: 'sms/inbox/',
|
|
1235
|
+
* region: 'us-east-1',
|
|
1236
|
+
* })
|
|
1237
|
+
* ```
|
|
1238
|
+
*/
|
|
1239
|
+
export function createSmsInboxHandler(config: {
|
|
1240
|
+
bucket: string
|
|
1241
|
+
prefix?: string
|
|
1242
|
+
region?: string
|
|
1243
|
+
onMessage?: (message: SmsMessage) => Promise<void>
|
|
1244
|
+
}) {
|
|
1245
|
+
const smsClient = new SmsClient({
|
|
1246
|
+
region: config.region || 'us-east-1',
|
|
1247
|
+
inboxBucket: config.bucket,
|
|
1248
|
+
inboxPrefix: config.prefix || 'sms/inbox/',
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
return async (event: any): Promise<any> => {
|
|
1252
|
+
console.log('Incoming SMS event:', JSON.stringify(event))
|
|
1253
|
+
|
|
1254
|
+
// Handle SNS event (from two-way SMS)
|
|
1255
|
+
if (event.Records) {
|
|
1256
|
+
for (const record of event.Records) {
|
|
1257
|
+
if (record.Sns) {
|
|
1258
|
+
const snsMessage = record.Sns
|
|
1259
|
+
let messageData: any
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
messageData = JSON.parse(snsMessage.Message)
|
|
1263
|
+
} catch {
|
|
1264
|
+
messageData = { body: snsMessage.Message }
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
const message = {
|
|
1268
|
+
from: messageData.originationNumber || messageData.from || 'unknown',
|
|
1269
|
+
to: messageData.destinationNumber || messageData.to || 'unknown',
|
|
1270
|
+
body: messageData.messageBody || messageData.body || messageData.message || '',
|
|
1271
|
+
messageId: messageData.inboundMessageId || snsMessage.MessageId,
|
|
1272
|
+
timestamp: new Date(snsMessage.Timestamp),
|
|
1273
|
+
raw: messageData,
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Store in S3
|
|
1277
|
+
const key = await smsClient.storeIncomingSms(message)
|
|
1278
|
+
console.log(`Stored incoming SMS: ${key}`)
|
|
1279
|
+
|
|
1280
|
+
// Call optional callback
|
|
1281
|
+
if (config.onMessage) {
|
|
1282
|
+
const storedMessage = await smsClient.getMessage(key)
|
|
1283
|
+
if (storedMessage) {
|
|
1284
|
+
await config.onMessage(storedMessage)
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
return {
|
|
1292
|
+
statusCode: 200,
|
|
1293
|
+
body: JSON.stringify({ message: 'SMS processed' }),
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Create a Lambda handler for sending scheduled SMS messages
|
|
1300
|
+
* This is invoked by EventBridge Scheduler at the scheduled time
|
|
1301
|
+
*
|
|
1302
|
+
* @example
|
|
1303
|
+
* ```typescript
|
|
1304
|
+
* // lambda.ts
|
|
1305
|
+
* import { createScheduledSmsHandler } from 'ts-cloud/aws/sms'
|
|
1306
|
+
*
|
|
1307
|
+
* export const handler = createScheduledSmsHandler({
|
|
1308
|
+
* bucket: 'my-sms-bucket',
|
|
1309
|
+
* region: 'us-east-1',
|
|
1310
|
+
* })
|
|
1311
|
+
* ```
|
|
1312
|
+
*/
|
|
1313
|
+
export function createScheduledSmsHandler(config: {
|
|
1314
|
+
bucket: string
|
|
1315
|
+
scheduledPrefix?: string
|
|
1316
|
+
region?: string
|
|
1317
|
+
onSent?: (sms: ScheduledSms) => Promise<void>
|
|
1318
|
+
onError?: (sms: ScheduledSms, error: Error) => Promise<void>
|
|
1319
|
+
}) {
|
|
1320
|
+
const smsClient = new SmsClient({
|
|
1321
|
+
region: config.region || 'us-east-1',
|
|
1322
|
+
scheduledBucket: config.bucket,
|
|
1323
|
+
scheduledPrefix: config.scheduledPrefix || 'sms/scheduled/',
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
return async (event: any): Promise<any> => {
|
|
1327
|
+
console.log('Scheduled SMS event:', JSON.stringify(event))
|
|
1328
|
+
|
|
1329
|
+
const { scheduledSmsId } = event
|
|
1330
|
+
|
|
1331
|
+
if (!scheduledSmsId) {
|
|
1332
|
+
console.error('Missing scheduledSmsId in event')
|
|
1333
|
+
return {
|
|
1334
|
+
statusCode: 400,
|
|
1335
|
+
body: JSON.stringify({ error: 'Missing scheduledSmsId' }),
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
const result = await smsClient.sendScheduledMessage(scheduledSmsId)
|
|
1341
|
+
console.log(`Sent scheduled SMS ${scheduledSmsId}: ${result.messageId}`)
|
|
1342
|
+
|
|
1343
|
+
if (config.onSent) {
|
|
1344
|
+
const sms = await smsClient.getScheduledMessage(scheduledSmsId)
|
|
1345
|
+
if (sms) await config.onSent(sms)
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return {
|
|
1349
|
+
statusCode: 200,
|
|
1350
|
+
body: JSON.stringify({ messageId: result.messageId }),
|
|
1351
|
+
}
|
|
1352
|
+
} catch (err: any) {
|
|
1353
|
+
console.error(`Failed to send scheduled SMS ${scheduledSmsId}:`, err.message)
|
|
1354
|
+
|
|
1355
|
+
if (config.onError) {
|
|
1356
|
+
const sms = await smsClient.getScheduledMessage(scheduledSmsId)
|
|
1357
|
+
if (sms) await config.onError(sms, err)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return {
|
|
1361
|
+
statusCode: 500,
|
|
1362
|
+
body: JSON.stringify({ error: err.message }),
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Convenience function to create an SMS client
|
|
1370
|
+
*/
|
|
1371
|
+
export function createSmsClient(config?: SmsClientConfig): SmsClient {
|
|
1372
|
+
return new SmsClient(config)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Create a Lambda handler for processing SMS delivery receipts
|
|
1377
|
+
* This handles SNS notifications for SMS delivery status events
|
|
1378
|
+
*
|
|
1379
|
+
* @example
|
|
1380
|
+
* ```typescript
|
|
1381
|
+
* // lambda.ts
|
|
1382
|
+
* import { createDeliveryReceiptHandler } from 'ts-cloud/aws/sms'
|
|
1383
|
+
*
|
|
1384
|
+
* export const handler = createDeliveryReceiptHandler({
|
|
1385
|
+
* bucket: 'my-sms-bucket',
|
|
1386
|
+
* region: 'us-east-1',
|
|
1387
|
+
* onDelivered: async (receipt) => {
|
|
1388
|
+
* console.log(`SMS ${receipt.messageId} delivered to ${receipt.to}`)
|
|
1389
|
+
* },
|
|
1390
|
+
* onFailed: async (receipt) => {
|
|
1391
|
+
* console.error(`SMS ${receipt.messageId} failed: ${receipt.errorMessage}`)
|
|
1392
|
+
* },
|
|
1393
|
+
* })
|
|
1394
|
+
* ```
|
|
1395
|
+
*/
|
|
1396
|
+
export function createDeliveryReceiptHandler(config: {
|
|
1397
|
+
bucket: string
|
|
1398
|
+
receiptPrefix?: string
|
|
1399
|
+
region?: string
|
|
1400
|
+
onDelivered?: (receipt: DeliveryReceipt) => Promise<void>
|
|
1401
|
+
onFailed?: (receipt: DeliveryReceipt) => Promise<void>
|
|
1402
|
+
onReceipt?: (receipt: DeliveryReceipt) => Promise<void>
|
|
1403
|
+
// Optional: webhook URL to forward receipts to
|
|
1404
|
+
webhookUrl?: string
|
|
1405
|
+
webhookSecret?: string
|
|
1406
|
+
}) {
|
|
1407
|
+
const smsClient = new SmsClient({
|
|
1408
|
+
region: config.region || 'us-east-1',
|
|
1409
|
+
receiptBucket: config.bucket,
|
|
1410
|
+
receiptPrefix: config.receiptPrefix || 'sms/receipts/',
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
return async (event: any): Promise<any> => {
|
|
1414
|
+
console.log('Delivery receipt event:', JSON.stringify(event))
|
|
1415
|
+
|
|
1416
|
+
const receipts: DeliveryReceipt[] = []
|
|
1417
|
+
|
|
1418
|
+
// Handle SNS events (delivery status notifications)
|
|
1419
|
+
if (event.Records) {
|
|
1420
|
+
for (const record of event.Records) {
|
|
1421
|
+
if (record.Sns) {
|
|
1422
|
+
const snsMessage = record.Sns
|
|
1423
|
+
let data: any
|
|
1424
|
+
|
|
1425
|
+
try {
|
|
1426
|
+
data = JSON.parse(snsMessage.Message)
|
|
1427
|
+
} catch {
|
|
1428
|
+
data = snsMessage.Message
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Parse different delivery status formats
|
|
1432
|
+
const receipt = parseDeliveryStatus(data, snsMessage)
|
|
1433
|
+
if (receipt) {
|
|
1434
|
+
receipts.push(receipt)
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Handle direct SNS SMS delivery status
|
|
1441
|
+
if (event.notification?.messageId) {
|
|
1442
|
+
const receipt = parseSnsDeliveryStatus(event)
|
|
1443
|
+
if (receipt) {
|
|
1444
|
+
receipts.push(receipt)
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Process all receipts
|
|
1449
|
+
for (const receipt of receipts) {
|
|
1450
|
+
// Store in S3
|
|
1451
|
+
await smsClient.storeDeliveryReceipt(receipt)
|
|
1452
|
+
console.log(`Stored delivery receipt: ${receipt.messageId} - ${receipt.status}`)
|
|
1453
|
+
|
|
1454
|
+
// Call callbacks
|
|
1455
|
+
if (config.onReceipt) {
|
|
1456
|
+
await config.onReceipt(receipt)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (receipt.status === 'delivered' && config.onDelivered) {
|
|
1460
|
+
await config.onDelivered(receipt)
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
if (receipt.status === 'failed' && config.onFailed) {
|
|
1464
|
+
await config.onFailed(receipt)
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Forward to webhook if configured
|
|
1468
|
+
if (config.webhookUrl) {
|
|
1469
|
+
await forwardToWebhook(config.webhookUrl, receipt, config.webhookSecret)
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
return {
|
|
1474
|
+
statusCode: 200,
|
|
1475
|
+
body: JSON.stringify({ processed: receipts.length }),
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Parse delivery status from various event formats
|
|
1482
|
+
*/
|
|
1483
|
+
function parseDeliveryStatus(data: any, snsMessage: any): DeliveryReceipt | null {
|
|
1484
|
+
// SNS SMS delivery status format
|
|
1485
|
+
if (data.status !== undefined && data.PhoneNumber) {
|
|
1486
|
+
const status = data.status === 'SUCCESS' ? 'delivered'
|
|
1487
|
+
: data.status === 'FAILURE' ? 'failed'
|
|
1488
|
+
: data.status === 'PENDING' ? 'pending'
|
|
1489
|
+
: 'unknown'
|
|
1490
|
+
|
|
1491
|
+
return {
|
|
1492
|
+
messageId: data.messageId || snsMessage.MessageId,
|
|
1493
|
+
status,
|
|
1494
|
+
timestamp: new Date(data.timestamp || snsMessage.Timestamp),
|
|
1495
|
+
to: data.PhoneNumber || data.destination || '',
|
|
1496
|
+
from: data.SenderId,
|
|
1497
|
+
errorCode: data.providerResponse?.statusCode,
|
|
1498
|
+
errorMessage: data.providerResponse?.statusMessage || data.providerResponse?.errorMessage,
|
|
1499
|
+
carrierName: data.providerResponse?.carrierName,
|
|
1500
|
+
priceInUsd: data.priceInUSD,
|
|
1501
|
+
messagePartCount: data.numberOfMessageParts,
|
|
1502
|
+
raw: data,
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Generic delivery notification
|
|
1507
|
+
if (data.messageId) {
|
|
1508
|
+
return {
|
|
1509
|
+
messageId: data.messageId,
|
|
1510
|
+
status: normalizeStatus(data.status || data.deliveryStatus || 'unknown'),
|
|
1511
|
+
timestamp: new Date(data.timestamp || snsMessage?.Timestamp || Date.now()),
|
|
1512
|
+
to: data.to || data.destination || data.phoneNumber || '',
|
|
1513
|
+
from: data.from || data.source,
|
|
1514
|
+
errorCode: data.errorCode,
|
|
1515
|
+
errorMessage: data.errorMessage || data.error,
|
|
1516
|
+
raw: data,
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return null
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Parse SNS SMS delivery status notification
|
|
1525
|
+
*/
|
|
1526
|
+
function parseSnsDeliveryStatus(event: any): DeliveryReceipt | null {
|
|
1527
|
+
const notification = event.notification
|
|
1528
|
+
|
|
1529
|
+
return {
|
|
1530
|
+
messageId: notification.messageId,
|
|
1531
|
+
status: normalizeStatus(notification.status),
|
|
1532
|
+
timestamp: new Date(notification.timestamp || Date.now()),
|
|
1533
|
+
to: event.destination || notification.destination,
|
|
1534
|
+
from: notification.senderId,
|
|
1535
|
+
errorCode: notification.providerResponse?.statusCode,
|
|
1536
|
+
errorMessage: notification.providerResponse?.statusMessage,
|
|
1537
|
+
carrierName: notification.providerResponse?.carrierName,
|
|
1538
|
+
priceInUsd: notification.priceInUSD,
|
|
1539
|
+
messagePartCount: notification.numberOfMessageParts,
|
|
1540
|
+
raw: event,
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Normalize status strings to DeliveryReceipt status
|
|
1546
|
+
*/
|
|
1547
|
+
function normalizeStatus(status: string): DeliveryReceipt['status'] {
|
|
1548
|
+
const s = (status || '').toUpperCase()
|
|
1549
|
+
if (s === 'DELIVERED' || s === 'SUCCESS' || s === 'TEXT_DELIVERED') return 'delivered'
|
|
1550
|
+
if (s === 'FAILED' || s === 'FAILURE' || s === 'TEXT_FAILED') return 'failed'
|
|
1551
|
+
if (s === 'SENT' || s === 'TEXT_SENT') return 'sent'
|
|
1552
|
+
if (s === 'PENDING' || s === 'QUEUED') return 'pending'
|
|
1553
|
+
return 'unknown'
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Forward delivery receipt to a webhook URL
|
|
1558
|
+
*/
|
|
1559
|
+
async function forwardToWebhook(
|
|
1560
|
+
url: string,
|
|
1561
|
+
receipt: DeliveryReceipt,
|
|
1562
|
+
secret?: string,
|
|
1563
|
+
): Promise<void> {
|
|
1564
|
+
const body = JSON.stringify(receipt)
|
|
1565
|
+
const headers: Record<string, string> = {
|
|
1566
|
+
'Content-Type': 'application/json',
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Add signature if secret provided (HMAC-SHA256)
|
|
1570
|
+
if (secret) {
|
|
1571
|
+
const crypto = await import('node:crypto')
|
|
1572
|
+
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
|
1573
|
+
headers['X-SMS-Signature'] = signature
|
|
1574
|
+
headers['X-SMS-Timestamp'] = Date.now().toString()
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
try {
|
|
1578
|
+
const response = await fetch(url, {
|
|
1579
|
+
method: 'POST',
|
|
1580
|
+
headers,
|
|
1581
|
+
body,
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
if (!response.ok) {
|
|
1585
|
+
console.error(`Webhook failed: ${response.status} ${response.statusText}`)
|
|
1586
|
+
}
|
|
1587
|
+
} catch (err: any) {
|
|
1588
|
+
console.error(`Webhook error: ${err.message}`)
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// ============================================
|
|
1593
|
+
// Phone Number Utilities
|
|
1594
|
+
// ============================================
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Normalize a phone number to E.164 format
|
|
1598
|
+
* Removes all non-numeric characters except leading +
|
|
1599
|
+
*/
|
|
1600
|
+
export function normalizePhoneNumber(phone: string): string {
|
|
1601
|
+
if (!phone) return ''
|
|
1602
|
+
|
|
1603
|
+
// Remove all non-numeric except +
|
|
1604
|
+
let normalized = phone.replace(/[^\d+]/g, '')
|
|
1605
|
+
|
|
1606
|
+
// If it starts with +, keep it, otherwise assume US
|
|
1607
|
+
if (!normalized.startsWith('+')) {
|
|
1608
|
+
// If 10 digits, assume US
|
|
1609
|
+
if (normalized.length === 10) {
|
|
1610
|
+
normalized = `+1${normalized}`
|
|
1611
|
+
}
|
|
1612
|
+
// If 11 digits and starts with 1, add +
|
|
1613
|
+
else if (normalized.length === 11 && normalized.startsWith('1')) {
|
|
1614
|
+
normalized = `+${normalized}`
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
return normalized
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Format a phone number for display
|
|
1623
|
+
*/
|
|
1624
|
+
export function formatPhoneNumber(phone: string, format: 'national' | 'international' | 'e164' = 'national'): string {
|
|
1625
|
+
const normalized = normalizePhoneNumber(phone)
|
|
1626
|
+
|
|
1627
|
+
if (format === 'e164') {
|
|
1628
|
+
return normalized
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// US number formatting
|
|
1632
|
+
if (normalized.startsWith('+1') && normalized.length === 12) {
|
|
1633
|
+
const number = normalized.slice(2)
|
|
1634
|
+
const areaCode = number.slice(0, 3)
|
|
1635
|
+
const exchange = number.slice(3, 6)
|
|
1636
|
+
const subscriber = number.slice(6)
|
|
1637
|
+
|
|
1638
|
+
if (format === 'national') {
|
|
1639
|
+
return `(${areaCode}) ${exchange}-${subscriber}`
|
|
1640
|
+
}
|
|
1641
|
+
return `+1 (${areaCode}) ${exchange}-${subscriber}`
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// For other countries, just return the normalized number
|
|
1645
|
+
return normalized
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Validate a phone number
|
|
1650
|
+
*/
|
|
1651
|
+
export function isValidPhoneNumber(phone: string): boolean {
|
|
1652
|
+
const normalized = normalizePhoneNumber(phone)
|
|
1653
|
+
|
|
1654
|
+
// Must start with + and have at least 10 digits
|
|
1655
|
+
if (!normalized.startsWith('+')) return false
|
|
1656
|
+
|
|
1657
|
+
const digits = normalized.slice(1)
|
|
1658
|
+
if (digits.length < 10 || digits.length > 15) return false
|
|
1659
|
+
|
|
1660
|
+
// Must be all digits
|
|
1661
|
+
return /^\d+$/.test(digits)
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Get the country code from a phone number
|
|
1666
|
+
*/
|
|
1667
|
+
export function getCountryCode(phone: string): string | null {
|
|
1668
|
+
const normalized = normalizePhoneNumber(phone)
|
|
1669
|
+
if (!normalized.startsWith('+')) return null
|
|
1670
|
+
|
|
1671
|
+
// Common country codes by length
|
|
1672
|
+
const countryCodes: Record<string, string> = {
|
|
1673
|
+
'1': 'US/CA',
|
|
1674
|
+
'7': 'RU/KZ',
|
|
1675
|
+
'20': 'EG',
|
|
1676
|
+
'27': 'ZA',
|
|
1677
|
+
'30': 'GR',
|
|
1678
|
+
'31': 'NL',
|
|
1679
|
+
'32': 'BE',
|
|
1680
|
+
'33': 'FR',
|
|
1681
|
+
'34': 'ES',
|
|
1682
|
+
'36': 'HU',
|
|
1683
|
+
'39': 'IT',
|
|
1684
|
+
'40': 'RO',
|
|
1685
|
+
'41': 'CH',
|
|
1686
|
+
'43': 'AT',
|
|
1687
|
+
'44': 'GB',
|
|
1688
|
+
'45': 'DK',
|
|
1689
|
+
'46': 'SE',
|
|
1690
|
+
'47': 'NO',
|
|
1691
|
+
'48': 'PL',
|
|
1692
|
+
'49': 'DE',
|
|
1693
|
+
'52': 'MX',
|
|
1694
|
+
'54': 'AR',
|
|
1695
|
+
'55': 'BR',
|
|
1696
|
+
'56': 'CL',
|
|
1697
|
+
'57': 'CO',
|
|
1698
|
+
'60': 'MY',
|
|
1699
|
+
'61': 'AU',
|
|
1700
|
+
'62': 'ID',
|
|
1701
|
+
'63': 'PH',
|
|
1702
|
+
'64': 'NZ',
|
|
1703
|
+
'65': 'SG',
|
|
1704
|
+
'66': 'TH',
|
|
1705
|
+
'81': 'JP',
|
|
1706
|
+
'82': 'KR',
|
|
1707
|
+
'84': 'VN',
|
|
1708
|
+
'86': 'CN',
|
|
1709
|
+
'90': 'TR',
|
|
1710
|
+
'91': 'IN',
|
|
1711
|
+
'92': 'PK',
|
|
1712
|
+
'93': 'AF',
|
|
1713
|
+
'94': 'LK',
|
|
1714
|
+
'98': 'IR',
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const digits = normalized.slice(1)
|
|
1718
|
+
|
|
1719
|
+
// Check 1-digit, then 2-digit, then 3-digit codes
|
|
1720
|
+
for (const len of [1, 2, 3]) {
|
|
1721
|
+
const prefix = digits.slice(0, len)
|
|
1722
|
+
if (countryCodes[prefix]) {
|
|
1723
|
+
return countryCodes[prefix]
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
return null
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/**
|
|
1731
|
+
* Check if two phone numbers are the same (ignoring formatting)
|
|
1732
|
+
*/
|
|
1733
|
+
export function isSamePhoneNumber(phone1: string, phone2: string): boolean {
|
|
1734
|
+
return normalizePhoneNumber(phone1) === normalizePhoneNumber(phone2)
|
|
1735
|
+
}
|