@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.
Files changed (75) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/package.json +18 -16
  3. package/src/aws/acm.ts +768 -0
  4. package/src/aws/application-autoscaling.ts +845 -0
  5. package/src/aws/bedrock.ts +4074 -0
  6. package/src/aws/client.ts +891 -0
  7. package/src/aws/cloudformation.ts +896 -0
  8. package/src/aws/cloudfront.ts +1531 -0
  9. package/src/aws/cloudwatch-logs.ts +154 -0
  10. package/src/aws/comprehend.ts +839 -0
  11. package/src/aws/connect.ts +1056 -0
  12. package/src/aws/deploy-imap.ts +384 -0
  13. package/src/aws/dynamodb.ts +340 -0
  14. package/src/aws/ec2.ts +1385 -0
  15. package/src/aws/ecr.ts +621 -0
  16. package/src/aws/ecs.ts +615 -0
  17. package/src/aws/elasticache.ts +301 -0
  18. package/src/aws/elbv2.ts +942 -0
  19. package/src/aws/email.ts +928 -0
  20. package/src/aws/eventbridge.ts +248 -0
  21. package/src/aws/iam.ts +1689 -0
  22. package/src/aws/imap-server.ts +2100 -0
  23. package/src/aws/index.ts +213 -0
  24. package/src/aws/kendra.ts +1097 -0
  25. package/src/aws/lambda.ts +786 -0
  26. package/src/aws/opensearch.ts +158 -0
  27. package/src/aws/personalize.ts +977 -0
  28. package/src/aws/polly.ts +559 -0
  29. package/src/aws/rds.ts +888 -0
  30. package/src/aws/rekognition.ts +846 -0
  31. package/src/aws/route53-domains.ts +359 -0
  32. package/src/aws/route53.ts +1046 -0
  33. package/src/aws/s3.ts +2334 -0
  34. package/src/aws/scheduler.ts +571 -0
  35. package/src/aws/secrets-manager.ts +769 -0
  36. package/src/aws/ses.ts +1081 -0
  37. package/src/aws/setup-phone.ts +104 -0
  38. package/src/aws/setup-sms.ts +580 -0
  39. package/src/aws/sms.ts +1735 -0
  40. package/src/aws/smtp-server.ts +531 -0
  41. package/src/aws/sns.ts +758 -0
  42. package/src/aws/sqs.ts +382 -0
  43. package/src/aws/ssm.ts +807 -0
  44. package/src/aws/sts.ts +92 -0
  45. package/src/aws/support.ts +391 -0
  46. package/src/aws/test-imap.ts +86 -0
  47. package/src/aws/textract.ts +780 -0
  48. package/src/aws/transcribe.ts +108 -0
  49. package/src/aws/translate.ts +641 -0
  50. package/src/aws/voice.ts +1379 -0
  51. package/src/config.ts +35 -0
  52. package/src/deploy/index.ts +7 -0
  53. package/src/deploy/static-site-external-dns.ts +945 -0
  54. package/src/deploy/static-site.ts +1175 -0
  55. package/src/dns/cloudflare.ts +548 -0
  56. package/src/dns/godaddy.ts +412 -0
  57. package/src/dns/index.ts +205 -0
  58. package/src/dns/porkbun.ts +362 -0
  59. package/src/dns/route53-adapter.ts +414 -0
  60. package/src/dns/types.ts +119 -0
  61. package/src/dns/validator.ts +369 -0
  62. package/src/generators/index.ts +5 -0
  63. package/src/generators/infrastructure.ts +1660 -0
  64. package/src/index.ts +163 -0
  65. package/src/push/apns.ts +452 -0
  66. package/src/push/fcm.ts +506 -0
  67. package/src/push/index.ts +58 -0
  68. package/src/security/pre-deploy-scanner.ts +655 -0
  69. package/src/ssl/acme-client.ts +478 -0
  70. package/src/ssl/index.ts +7 -0
  71. package/src/ssl/letsencrypt.ts +747 -0
  72. package/src/types.ts +2 -0
  73. package/src/utils/cli.ts +398 -0
  74. package/src/validation/index.ts +5 -0
  75. 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
+ }