belt-messaging 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +224 -0
- data/lib/belt/generators/messaging_generator.rb +316 -0
- data/lib/belt/messaging/configuration.rb +41 -0
- data/lib/belt/messaging/controllers/messaging_events_controller.rb +57 -0
- data/lib/belt/messaging/controllers/sms_verification_controller.rb +54 -0
- data/lib/belt/messaging/email.rb +107 -0
- data/lib/belt/messaging/email_gate.rb +35 -0
- data/lib/belt/messaging/logging.rb +20 -0
- data/lib/belt/messaging/phone_formatter.rb +43 -0
- data/lib/belt/messaging/sms.rb +64 -0
- data/lib/belt/messaging/sms_gate.rb +34 -0
- data/lib/belt/messaging/templates/config/messaging_events.yml.erb +41 -0
- data/lib/belt/messaging/templates/controllers/messaging_events_controller.rb.erb +43 -0
- data/lib/belt/messaging/templates/controllers/sms_verification_controller.rb.erb +46 -0
- data/lib/belt/messaging/templates/lambda/messaging_events.rb.erb +76 -0
- data/lib/belt/messaging/templates/terraform/main.tf.erb +266 -0
- data/lib/belt/messaging/templates/terraform/outputs.tf.erb +52 -0
- data/lib/belt/messaging/templates/terraform/variables.tf.erb +66 -0
- data/lib/belt/messaging/version.rb +7 -0
- data/lib/belt/messaging.rb +58 -0
- data/lib/belt-messaging.rb +3 -0
- metadata +112 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Messaging
|
|
5
|
+
module Controllers
|
|
6
|
+
# Default SMS verification controller.
|
|
7
|
+
# Provides send-code and verify-code actions.
|
|
8
|
+
#
|
|
9
|
+
# To customize, generate the controller override:
|
|
10
|
+
# belt g messaging
|
|
11
|
+
#
|
|
12
|
+
# Then modify lambda/controllers/<app>/sms_verification_controller.rb
|
|
13
|
+
#
|
|
14
|
+
# This default implementation requires you to handle persistence yourself
|
|
15
|
+
# via the on_code_generated and on_code_verified callbacks.
|
|
16
|
+
class SmsVerificationController < BeltController::Base
|
|
17
|
+
# POST /sms-verification — send a verification code
|
|
18
|
+
def create
|
|
19
|
+
phone = params[:phone]
|
|
20
|
+
return error_response('Phone number is required', 422) if phone.to_s.strip.empty?
|
|
21
|
+
|
|
22
|
+
formatted = PhoneFormatter.format_e164(phone)
|
|
23
|
+
return error_response('Invalid phone number format', 422) unless formatted
|
|
24
|
+
|
|
25
|
+
code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
|
|
26
|
+
|
|
27
|
+
sent = Belt::Messaging.send_sms(
|
|
28
|
+
to: formatted,
|
|
29
|
+
message: "Your verification code is: #{code}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
unless sent
|
|
33
|
+
return error_response('Unable to send SMS. Please try again later.', 503)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Belt::Messaging.log(:info, 'SMS verification code sent', phone: formatted)
|
|
37
|
+
success_response(message: 'Verification code sent')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# PUT /sms-verification — verify the code
|
|
41
|
+
def update
|
|
42
|
+
code = params[:code].to_s.strip
|
|
43
|
+
return error_response('Verification code is required', 422) if code.empty?
|
|
44
|
+
|
|
45
|
+
# Default: no persistence layer — override this controller
|
|
46
|
+
Belt::Messaging.log(:warn,
|
|
47
|
+
'SMS verification attempted but no persistence configured. Override SmsVerificationController.')
|
|
48
|
+
|
|
49
|
+
success_response(message: 'Verification not implemented — override the controller')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aws-sdk-sesv2'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module Messaging
|
|
7
|
+
# Core email sending via AWS SESv2.
|
|
8
|
+
module Email
|
|
9
|
+
extend Logging
|
|
10
|
+
|
|
11
|
+
# Send a transactional email.
|
|
12
|
+
#
|
|
13
|
+
# All recipients must pass the EmailGate check. If any recipient is blocked,
|
|
14
|
+
# the entire send is rejected (returns false). This mirrors SMS behavior where
|
|
15
|
+
# a blocked recipient causes the send to fail. Callers who want partial delivery
|
|
16
|
+
# should filter recipients through EmailGate.allowed? before calling send.
|
|
17
|
+
#
|
|
18
|
+
# @param to [String, Array<String>] Recipient email address(es)
|
|
19
|
+
# @param subject [String] Email subject
|
|
20
|
+
# @param body [String] Email body (plain text)
|
|
21
|
+
# @param html [String, nil] Optional HTML body
|
|
22
|
+
# @param from [String, nil] Override the configured from address
|
|
23
|
+
# @return [Boolean] true if sent successfully, false otherwise
|
|
24
|
+
def self.send(to:, subject:, body:, html: nil, from: nil)
|
|
25
|
+
config = Belt::Messaging.configuration
|
|
26
|
+
|
|
27
|
+
unless config.email_enabled?
|
|
28
|
+
log(:warn, 'Email sending is disabled (ENABLE_EMAIL != true)')
|
|
29
|
+
return false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
recipients = Array(to)
|
|
33
|
+
if recipients.empty?
|
|
34
|
+
log(:error, 'No recipients provided')
|
|
35
|
+
return false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
invalid = recipients.reject { |email| valid_email?(email) }
|
|
39
|
+
if invalid.any?
|
|
40
|
+
log(:error, 'Invalid email address format', invalid: invalid)
|
|
41
|
+
return false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
blocked = recipients.reject { |email| EmailGate.allowed?(email) }
|
|
45
|
+
if blocked.any?
|
|
46
|
+
log(:warn, 'Email recipients blocked by current EMAIL_MODE',
|
|
47
|
+
blocked: blocked, mode: config.email_mode)
|
|
48
|
+
return false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
from_address = from || config.email_from_address
|
|
52
|
+
if from_address.nil? || from_address.empty?
|
|
53
|
+
log(:error, 'EMAIL_FROM_ADDRESS not configured')
|
|
54
|
+
return false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
client = Aws::SESV2::Client.new(region: config.aws_region)
|
|
59
|
+
|
|
60
|
+
email_content = build_email_content(subject, body, html)
|
|
61
|
+
|
|
62
|
+
client.send_email({
|
|
63
|
+
from_email_address: from_address,
|
|
64
|
+
destination: { to_addresses: recipients },
|
|
65
|
+
content: { simple: email_content }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
log(:info, 'Email sent successfully', to: recipients, subject: subject)
|
|
69
|
+
true
|
|
70
|
+
rescue Aws::SESV2::Errors::AccessDeniedException => e
|
|
71
|
+
raise Belt::Messaging::Error,
|
|
72
|
+
"Email AccessDenied: ses:SendEmail not allowed. " \
|
|
73
|
+
"Check SES permissions in your messaging terraform module. " \
|
|
74
|
+
"Original: #{e.message}"
|
|
75
|
+
rescue Aws::SESV2::Errors::MessageRejected => e
|
|
76
|
+
log(:error, 'Email rejected by SES', error: e.message, to: recipients)
|
|
77
|
+
false
|
|
78
|
+
rescue Aws::SESV2::Errors::ServiceError => e
|
|
79
|
+
log(:error, 'Failed to send email', error: e.message, to: recipients)
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Validate basic email format.
|
|
85
|
+
# @param email [String]
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def self.valid_email?(email)
|
|
88
|
+
return false if email.nil? || email.empty?
|
|
89
|
+
|
|
90
|
+
# Basic validation: has @ with something before and after
|
|
91
|
+
email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.build_email_content(subject, body, html)
|
|
95
|
+
content = {
|
|
96
|
+
subject: { data: subject, charset: 'UTF-8' },
|
|
97
|
+
body: {
|
|
98
|
+
text: { data: body, charset: 'UTF-8' }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
content[:body][:html] = { data: html, charset: 'UTF-8' } if html
|
|
102
|
+
content
|
|
103
|
+
end
|
|
104
|
+
private_class_method :build_email_content
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Messaging
|
|
5
|
+
# Controls whether emails are actually sent based on EMAIL_MODE.
|
|
6
|
+
# Modes:
|
|
7
|
+
# - "none" — never send (default, safe for development)
|
|
8
|
+
# - "whitelist" — only send to addresses in EMAIL_WHITELIST
|
|
9
|
+
# - "all" — send to everyone (production)
|
|
10
|
+
module EmailGate
|
|
11
|
+
extend Logging
|
|
12
|
+
|
|
13
|
+
# Check if a given email address is allowed to receive email.
|
|
14
|
+
# @param email [String] Email address
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def self.allowed?(email)
|
|
17
|
+
config = Belt::Messaging.configuration
|
|
18
|
+
mode = config.email_mode
|
|
19
|
+
|
|
20
|
+
case mode
|
|
21
|
+
when 'none'
|
|
22
|
+
false
|
|
23
|
+
when 'all'
|
|
24
|
+
true
|
|
25
|
+
when 'whitelist'
|
|
26
|
+
normalized = email.to_s.downcase.strip
|
|
27
|
+
config.email_whitelist.any? { |allowed| allowed.downcase.strip == normalized }
|
|
28
|
+
else
|
|
29
|
+
log(:warn, 'Unknown EMAIL_MODE, defaulting to none', mode: mode)
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Messaging
|
|
5
|
+
# Shared logging helper for messaging modules.
|
|
6
|
+
# Routes to the configured logger, or falls back to Belt::Observability::Logger.
|
|
7
|
+
module Logging
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def log(level, message, **kwargs)
|
|
11
|
+
logger = Belt::Messaging.configuration.logger
|
|
12
|
+
if logger
|
|
13
|
+
logger.public_send(level, message, **kwargs)
|
|
14
|
+
elsif defined?(Belt::Observability::Logger)
|
|
15
|
+
Belt::Observability::Logger.public_send(level, message, **kwargs)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Messaging
|
|
5
|
+
# Formats phone numbers to E.164 format (+1XXXXXXXXXX for US numbers).
|
|
6
|
+
module PhoneFormatter
|
|
7
|
+
# Format a phone number to E.164.
|
|
8
|
+
# @param phone [String] Phone number in any common US format
|
|
9
|
+
# @return [String, nil] E.164 formatted number, or nil if invalid
|
|
10
|
+
def self.format_e164(phone)
|
|
11
|
+
return nil if phone.nil? || phone.to_s.strip.empty?
|
|
12
|
+
|
|
13
|
+
# Already has + prefix — validate it
|
|
14
|
+
if phone.start_with?('+')
|
|
15
|
+
digits = phone.gsub(/\D/, '')
|
|
16
|
+
return "+#{digits}" if digits.length >= 11
|
|
17
|
+
return nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Remove all non-digit characters
|
|
21
|
+
digits = phone.gsub(/\D/, '')
|
|
22
|
+
|
|
23
|
+
case digits.length
|
|
24
|
+
when 10
|
|
25
|
+
# US number without country code
|
|
26
|
+
"+1#{digits}"
|
|
27
|
+
when 11
|
|
28
|
+
# US number with country code
|
|
29
|
+
digits.start_with?('1') ? "+#{digits}" : nil
|
|
30
|
+
else
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if a string looks like a valid phone number.
|
|
36
|
+
# @param phone [String] Phone number to validate
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def self.valid?(phone)
|
|
39
|
+
!format_e164(phone).nil?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aws-sdk-pinpointsmsvoicev2'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module Messaging
|
|
7
|
+
# Core SMS sending via AWS PinpointSMSVoiceV2.
|
|
8
|
+
module SMS
|
|
9
|
+
extend Logging
|
|
10
|
+
|
|
11
|
+
# Send an SMS message.
|
|
12
|
+
# @param to [String] Recipient phone number (any US format)
|
|
13
|
+
# @param message [String] Message body (max 160 chars for single segment)
|
|
14
|
+
# @return [Boolean] true if sent successfully, false otherwise
|
|
15
|
+
def self.send(to:, message:)
|
|
16
|
+
config = Belt::Messaging.configuration
|
|
17
|
+
|
|
18
|
+
unless config.enabled?
|
|
19
|
+
log(:warn, 'SMS sending is disabled (ENABLE_SMS != true)')
|
|
20
|
+
return false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
formatted_phone = PhoneFormatter.format_e164(to)
|
|
24
|
+
unless formatted_phone
|
|
25
|
+
log(:error, 'Invalid phone number format', phone: to)
|
|
26
|
+
return false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless SmsGate.allowed?(formatted_phone)
|
|
30
|
+
log(:warn, 'SMS recipient not allowed by current SMS_MODE', to: formatted_phone, mode: config.sms_mode)
|
|
31
|
+
return false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
origination = config.origination_number
|
|
35
|
+
if origination.nil? || origination.empty?
|
|
36
|
+
log(:error, 'SMS_ORIGINATION_NUMBER not configured')
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
client = Aws::PinpointSMSVoiceV2::Client.new(region: config.aws_region)
|
|
42
|
+
|
|
43
|
+
client.send_text_message({
|
|
44
|
+
destination_phone_number: formatted_phone,
|
|
45
|
+
origination_identity: origination,
|
|
46
|
+
message_body: message,
|
|
47
|
+
message_type: 'TRANSACTIONAL'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
log(:info, 'SMS sent successfully', to: formatted_phone)
|
|
51
|
+
true
|
|
52
|
+
rescue Aws::PinpointSMSVoiceV2::Errors::AccessDeniedException => e
|
|
53
|
+
raise Belt::Messaging::Error,
|
|
54
|
+
"SMS AccessDenied: sms-voice:SendTextMessage not allowed. " \
|
|
55
|
+
"Check Pinpoint SMS permissions in your messaging terraform module. " \
|
|
56
|
+
"Original: #{e.message}"
|
|
57
|
+
rescue Aws::PinpointSMSVoiceV2::Errors::ServiceError => e
|
|
58
|
+
log(:error, 'Failed to send SMS', error: e.message, to: formatted_phone)
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Messaging
|
|
5
|
+
# Controls whether SMS messages are actually sent based on SMS_MODE.
|
|
6
|
+
# Modes:
|
|
7
|
+
# - "none" — never send (default, safe for development)
|
|
8
|
+
# - "whitelist" — only send to numbers in SMS_WHITELIST
|
|
9
|
+
# - "all" — send to everyone (production)
|
|
10
|
+
module SmsGate
|
|
11
|
+
extend Logging
|
|
12
|
+
|
|
13
|
+
# Check if a given phone number is allowed to receive SMS.
|
|
14
|
+
# @param phone [String] E.164 formatted phone number
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def self.allowed?(phone)
|
|
17
|
+
config = Belt::Messaging.configuration
|
|
18
|
+
mode = config.sms_mode
|
|
19
|
+
|
|
20
|
+
case mode
|
|
21
|
+
when 'none'
|
|
22
|
+
false
|
|
23
|
+
when 'all'
|
|
24
|
+
true
|
|
25
|
+
when 'whitelist'
|
|
26
|
+
config.sms_whitelist.include?(phone)
|
|
27
|
+
else
|
|
28
|
+
log(:warn, 'Unknown SMS_MODE, defaulting to none', mode: mode)
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Lambda configuration for the messaging_events function.
|
|
2
|
+
# Handles inbound SMS events (STOP/START keywords) via SNS two-way messaging.
|
|
3
|
+
#
|
|
4
|
+
# Generated by: belt generate messaging
|
|
5
|
+
#
|
|
6
|
+
# Keys:
|
|
7
|
+
# timeout Lambda timeout in seconds
|
|
8
|
+
# memory_size Lambda memory in MB
|
|
9
|
+
# env_vars Environment variables (static values or ref() for Terraform values)
|
|
10
|
+
# sns_triggers SNS topic triggers (inbound SMS events)
|
|
11
|
+
#
|
|
12
|
+
# ref(name) references are resolved from lambda_env_refs in Terraform.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# belt lambda-config -e prod # View merged config for prod
|
|
16
|
+
# belt lambda-config -f terraform # Output as Terraform-ready lambda_config
|
|
17
|
+
|
|
18
|
+
default: &default
|
|
19
|
+
timeout: 30
|
|
20
|
+
memory_size: 256
|
|
21
|
+
env_vars:
|
|
22
|
+
ENABLE_SMS: "true"
|
|
23
|
+
SMS_MODE: ref(sms_mode)
|
|
24
|
+
SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
|
|
25
|
+
sns_triggers:
|
|
26
|
+
- ref(sms_inbound_topic_arn)
|
|
27
|
+
|
|
28
|
+
dev:
|
|
29
|
+
<<: *default
|
|
30
|
+
env_vars:
|
|
31
|
+
ENABLE_SMS: "true"
|
|
32
|
+
SMS_MODE: "whitelist"
|
|
33
|
+
SMS_WHITELIST: ref(sms_whitelist)
|
|
34
|
+
SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
|
|
35
|
+
|
|
36
|
+
prod:
|
|
37
|
+
<<: *default
|
|
38
|
+
env_vars:
|
|
39
|
+
ENABLE_SMS: "true"
|
|
40
|
+
SMS_MODE: "all"
|
|
41
|
+
SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by: belt generate messaging
|
|
4
|
+
# Override this controller to customize inbound SMS opt-in/opt-out handling.
|
|
5
|
+
# The default implementation is in Belt::Messaging::Controllers::MessagingEventsController.
|
|
6
|
+
|
|
7
|
+
require 'belt'
|
|
8
|
+
require 'belt/messaging'
|
|
9
|
+
|
|
10
|
+
class MessagingEventsController < BeltController::Base
|
|
11
|
+
OPT_OUT_KEYWORDS = Belt::Messaging::Controllers::MessagingEventsController::OPT_OUT_KEYWORDS
|
|
12
|
+
OPT_IN_KEYWORDS = Belt::Messaging::Controllers::MessagingEventsController::OPT_IN_KEYWORDS
|
|
13
|
+
|
|
14
|
+
def opt_out
|
|
15
|
+
phone = sms_origin_phone
|
|
16
|
+
Belt::Observability::Logger.info('Processing SMS opt-out', phone: phone)
|
|
17
|
+
|
|
18
|
+
# Implement your opt-out logic here.
|
|
19
|
+
# Example: User.where(phone: phone).each { |u| u.update(sms_opted_out: true) }
|
|
20
|
+
|
|
21
|
+
success_response(success: true, processed: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def opt_in
|
|
25
|
+
phone = sms_origin_phone
|
|
26
|
+
Belt::Observability::Logger.info('Processing SMS opt-in', phone: phone)
|
|
27
|
+
|
|
28
|
+
# Implement your opt-in logic here.
|
|
29
|
+
# Example: User.where(phone: phone).each { |u| u.update(sms_opted_out: false) }
|
|
30
|
+
|
|
31
|
+
success_response(success: true, processed: true)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def sms_origin_phone
|
|
37
|
+
records = @event['Records']
|
|
38
|
+
return nil unless records&.first
|
|
39
|
+
|
|
40
|
+
message = JSON.parse(records.first.dig('Sns', 'Message') || '{}')
|
|
41
|
+
message['originationNumber']
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by: belt generate messaging
|
|
4
|
+
# Override this controller to customize SMS verification behavior.
|
|
5
|
+
# The default implementation is in Belt::Messaging::Controllers::SmsVerificationController.
|
|
6
|
+
|
|
7
|
+
require 'belt'
|
|
8
|
+
require 'belt/messaging'
|
|
9
|
+
|
|
10
|
+
class SmsVerificationController < BeltController::Base
|
|
11
|
+
# POST /sms-verification — send a 6-digit verification code via SMS
|
|
12
|
+
def create
|
|
13
|
+
phone = params[:phone]
|
|
14
|
+
return error_response('Phone number is required', 422) if phone.to_s.strip.empty?
|
|
15
|
+
|
|
16
|
+
code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
|
|
17
|
+
|
|
18
|
+
sent = Belt::Messaging.send_sms(
|
|
19
|
+
to: phone,
|
|
20
|
+
message: "Your verification code is: #{code}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
unless sent
|
|
24
|
+
return error_response('Unable to send SMS. Please try again later.', 503)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Store the code — implement your own persistence here.
|
|
28
|
+
# Example: current_user.update(sms_verification_code: code, sms_verification_expires_at: 10.minutes.from_now)
|
|
29
|
+
|
|
30
|
+
success_response(message: 'Verification code sent')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# PUT /sms-verification — verify the code
|
|
34
|
+
def update
|
|
35
|
+
code = params[:code].to_s.strip
|
|
36
|
+
return error_response('Verification code is required', 422) if code.empty?
|
|
37
|
+
|
|
38
|
+
# Implement your own verification logic here.
|
|
39
|
+
# Example:
|
|
40
|
+
# stored_code = current_user.sms_verification_code
|
|
41
|
+
# return error_response('Invalid code', 422) unless code == stored_code
|
|
42
|
+
# current_user.update(phone_verified: true, sms_verification_code: nil)
|
|
43
|
+
|
|
44
|
+
success_response(message: 'Phone number verified', phone_verified: true)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by: belt generate messaging
|
|
4
|
+
# Lambda entry point for inbound SMS events (two-way messaging).
|
|
5
|
+
# Receives SNS notifications when customers text STOP/START to your number.
|
|
6
|
+
|
|
7
|
+
require_relative 'config/environment'
|
|
8
|
+
|
|
9
|
+
include Belt::LambdaHandler
|
|
10
|
+
|
|
11
|
+
ROUTER = Belt::ActionRouter.new(
|
|
12
|
+
routes: [
|
|
13
|
+
{ verb: 'POST', path: '/opt_out', controller: 'messaging_events', action: 'opt_out' },
|
|
14
|
+
{ verb: 'POST', path: '/opt_in', controller: 'messaging_events', action: 'opt_in' }
|
|
15
|
+
],
|
|
16
|
+
namespace: 'messaging_events'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def execute(path:, body:, event:)
|
|
20
|
+
ROUTER.route(event: event, body: body)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Override lambda_handler to detect SNS events from two-way messaging.
|
|
24
|
+
# Pinpoint forwards inbound SMS via SNS — the event has a Records array
|
|
25
|
+
# instead of httpMethod/path. We translate it into a synthetic API Gateway event.
|
|
26
|
+
def lambda_handler(event:, context:)
|
|
27
|
+
if event['Records'] && event.dig('Records', 0, 'EventSource') == 'aws:sns'
|
|
28
|
+
handle_sns_event(event: event, context: context)
|
|
29
|
+
else
|
|
30
|
+
super(event: event, context: context)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def handle_sns_event(event:, context:)
|
|
37
|
+
init_observability(context: context)
|
|
38
|
+
|
|
39
|
+
event['Records'].each do |record|
|
|
40
|
+
message = JSON.parse(record['Sns']['Message'])
|
|
41
|
+
keyword = message['messageBody']&.strip&.upcase
|
|
42
|
+
|
|
43
|
+
opt_out_keywords = Belt::Messaging::Controllers::MessagingEventsController::OPT_OUT_KEYWORDS
|
|
44
|
+
opt_in_keywords = Belt::Messaging::Controllers::MessagingEventsController::OPT_IN_KEYWORDS
|
|
45
|
+
|
|
46
|
+
action_path = if opt_out_keywords.include?(keyword)
|
|
47
|
+
'/opt_out'
|
|
48
|
+
elsif opt_in_keywords.include?(keyword)
|
|
49
|
+
'/opt_in'
|
|
50
|
+
else
|
|
51
|
+
Belt::Observability::Logger.info('Ignoring unrecognized SMS keyword',
|
|
52
|
+
keyword: keyword, phone: message['originationNumber'])
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Belt::Observability::Logger.info('Routing inbound SMS',
|
|
57
|
+
keyword: keyword, path: action_path, phone: message['originationNumber'])
|
|
58
|
+
|
|
59
|
+
synthetic_event = {
|
|
60
|
+
'httpMethod' => 'POST',
|
|
61
|
+
'path' => action_path,
|
|
62
|
+
'headers' => {},
|
|
63
|
+
'queryStringParameters' => {},
|
|
64
|
+
'pathParameters' => {},
|
|
65
|
+
'Records' => event['Records'],
|
|
66
|
+
'body' => nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
execute(path: action_path, body: {}, event: synthetic_event)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
{ statusCode: 200, body: JSON.generate(success: true) }
|
|
73
|
+
rescue => e
|
|
74
|
+
Belt::Observability::Logger.error('Unhandled error processing inbound SMS', error: e.message)
|
|
75
|
+
{ statusCode: 500, body: JSON.generate(error: 'Internal server error') }
|
|
76
|
+
end
|