Module: SmsRu::Webhook
- Defined in:
- lib/sms_ru/webhook.rb
Overview
Parses the inbound webhook payload SMS.ru POSTs to your callback URL and
verifies its signature. SMS.ru sends the records as POST fields
data[0]..data[N] plus a hash field; acknowledge the webhook by replying
with "100".
The data param arrives as a Hash in bare Rack and an Array in PHP-style
clients. In Rails it is an ActionController::Parameters, which is not a
Hash — convert it with .to_unsafe_h first, or the numeric-key ordering the
signature depends on is skipped and Webhook.valid? rejects the payload. The
payload is signature-verified, so to_unsafe_h is safe here (.to_h would
drop unpermitted keys).
Webhook.parse returns one typed event per record — a Events::SmsStatus, Events::CallcheckStatus, Events::Test, or Events::Unknown — best handled with a case match:
data = params[:data].to_unsafe_h # Rails; pass params["data"] as-is in bare Rack
return head(:forbidden) unless SmsRu::Webhook.valid?(data, params[:hash], api_id)
SmsRu::Webhook.parse(data).each do |event|
case event
when SmsRu::Events::SmsStatus then update_delivery(event.id, event.status_code)
when SmsRu::Events::CallcheckStatus then confirm(event.id) if event.confirmed?
end
end
Class Method Summary collapse
-
.entries(data) ⇒ Array<String>
private
Normalizes the
dataparam to an ordered Array of record strings. -
.parse(data) ⇒ Array<SmsRu::Events::SmsStatus, SmsRu::Events::CallcheckStatus, SmsRu::Events::Test, SmsRu::Events::Unknown>
One event per record.
-
.status_fields(lines) ⇒ Object
private
Common fields of the "type / id / status / timestamp" status records.
-
.time(str) ⇒ Object
private
Converts a unix-timestamp line into a Time, or nil when absent.
-
.valid?(data, hash, api_id) ⇒ Boolean
Verifies the payload genuinely came from SMS.ru (constant-time compare of SMS.ru's
hashagainstsha256(api_id + concatenated data entries)).
Class Method Details
.entries(data) ⇒ Array<String>
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Normalizes the data param to an ordered Array of record strings. SMS.ru
numbers the fields data; Rack delivers them as a Hash, so sort by
the numeric key to preserve SMS.ru's order (the signature depends on it).
71 72 73 |
# File 'lib/sms_ru/webhook.rb', line 71 def self.entries(data) data.is_a?(Hash) ? data.sort_by { |k, _| Coerce.integer(k) }.map(&:last) : Array(data) end |
.parse(data) ⇒ Array<SmsRu::Events::SmsStatus, SmsRu::Events::CallcheckStatus, SmsRu::Events::Test, SmsRu::Events::Unknown>
Returns one event per record.
32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/sms_ru/webhook.rb', line 32 def self.parse(data) entries(data).map do |entry| lines = entry.to_s.split("\n") case lines[0] when "sms_status" then Events::SmsStatus.new(**status_fields(lines)) when "callcheck_status" then Events::CallcheckStatus.new(**status_fields(lines)) when "test" then Events::Test.new(created_at: time(lines[1]), raw: lines) else Events::Unknown.new(type: lines[0].to_s, raw: lines) end end end |
.status_fields(lines) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Common fields of the "type / id / status / timestamp" status records.
60 61 62 |
# File 'lib/sms_ru/webhook.rb', line 60 def self.status_fields(lines) { id: lines[1].to_s, status_code: Coerce.integer?(lines[2]), created_at: time(lines[3]), raw: lines } end |
.time(str) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Converts a unix-timestamp line into a Time, or nil when absent.
77 78 79 80 |
# File 'lib/sms_ru/webhook.rb', line 77 def self.time(str) unix = Coerce.integer?(str) unix && Time.at(unix) end |
.valid?(data, hash, api_id) ⇒ Boolean
Verifies the payload genuinely came from SMS.ru (constant-time compare of
SMS.ru's hash against sha256(api_id + concatenated data entries)).
51 52 53 54 55 56 |
# File 'lib/sms_ru/webhook.rb', line 51 def self.valid?(data, hash, api_id) return false unless hash.is_a?(String) expected = OpenSSL::Digest::SHA256.hexdigest("#{api_id}#{entries(data).join}") expected.bytesize == hash.bytesize && OpenSSL.fixed_length_secure_compare(expected, hash) end |