mirror of https://github.com/docusealco/docuseal
Adds three new selectable providers behind the existing Sms.send_message interface. Per-account credentials are namespaced in the encrypted sms_configs hash (twilio_*, voipms_*, signalwire_*) so existing BulkVS configs keep working unchanged. - lib/sms.rb: dispatch via per-provider classes and delegate the "is this configured" check to each provider, replacing the BulkVS-only hardcoded gate in enabled_for?. - lib/sms/providers/twilio.rb: form-encoded POST to the Messages API, Basic Auth with SID:Token, treats 201-with-error_code as failure. - lib/sms/providers/voipms.rb: GET with query-string auth, treats status != "success" as failure even on HTTP 200, enforces the API's 160-byte hard cap up front. - lib/sms/providers/signalwire.rb: Twilio-shaped client targeting the per-account Space URL host; strips https:// and trailing / from the user-supplied space URL. - app/controllers/sms_settings_controller.rb: extend the preserve-secret-on-blank-edit pattern to all four providers' password fields via a SECRET_KEYS array. - app/views/sms_settings/index.html.erb: dynamic provider select sourced from Sms::SUPPORTED_PROVIDERS with per-provider field blocks toggled by a nonce'd inline script (the app's CSP requires nonces on inline JS). - SMS.md: new "Configuring …" sections for each provider, wire-format quick-reference table, and updated extension/code-map sections. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>pull/687/head
parent
ee5a295e57
commit
0ac37338ab
@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sms
|
||||
module Providers
|
||||
# Thin wrapper around the SignalWire Compatibility API Messages endpoint.
|
||||
# Twilio-shaped on the wire (same Basic Auth, same form-encoded body, same
|
||||
# 201-with-error_code JSON), with two differences from Twilio:
|
||||
# - path is /api/laml/2010-04-01/Accounts/<id>/Messages (no .json suffix)
|
||||
# - host comes from a per-account "Space URL" (e.g. acme.signalwire.com)
|
||||
#
|
||||
# Docs: https://signalwire.com/docs/compatibility-api/rest/messages/create-message
|
||||
class Signalwire
|
||||
TIMEOUT_SECONDS = 15
|
||||
|
||||
def self.configured?(config)
|
||||
config['signalwire_space_url'].to_s.present? &&
|
||||
config['signalwire_project_id'].to_s.present? &&
|
||||
config['signalwire_api_token'].to_s.present? &&
|
||||
config['signalwire_from'].to_s.present?
|
||||
end
|
||||
|
||||
def initialize(config)
|
||||
@host = normalize_space_url(config['signalwire_space_url'])
|
||||
@project_id = config['signalwire_project_id'].to_s.strip
|
||||
@token = config['signalwire_api_token'].to_s.strip
|
||||
@from = format_e164(config['signalwire_from'])
|
||||
end
|
||||
|
||||
def deliver(to:, text:, webhook: nil)
|
||||
form = {
|
||||
'From' => @from,
|
||||
'To' => format_e164(to),
|
||||
'Body' => text.to_s
|
||||
}
|
||||
form['StatusCallback'] = webhook if webhook.present?
|
||||
|
||||
response = http_post(form)
|
||||
parse_response!(response, form)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_space_url(raw)
|
||||
raw.to_s.strip.sub(%r{\Ahttps?://}, '').sub(%r{/\z}, '')
|
||||
end
|
||||
|
||||
def format_e164(raw)
|
||||
"+#{Sms.normalize_phone(raw)}"
|
||||
end
|
||||
|
||||
def http_post(form)
|
||||
uri = URI("https://#{@host}/api/laml/2010-04-01/Accounts/#{@project_id}/Messages")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = TIMEOUT_SECONDS
|
||||
http.open_timeout = TIMEOUT_SECONDS
|
||||
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
request.basic_auth(@project_id, @token)
|
||||
request['Accept'] = 'application/json'
|
||||
request.set_form_data(form)
|
||||
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
def parse_response!(response, form)
|
||||
body = begin
|
||||
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
{ 'raw' => response.body.to_s }
|
||||
end
|
||||
|
||||
if response.is_a?(Net::HTTPSuccess) && body['error_code'].nil?
|
||||
return body
|
||||
end
|
||||
|
||||
code = body['code'] || body['error_code']
|
||||
message = body['message'] || body['error_message'] || body['raw'] || "HTTP #{response.code}"
|
||||
detail = code ? "#{code} #{message}" : message
|
||||
raise Sms::ProviderError,
|
||||
"SignalWire rejected request (HTTP #{response.code}): #{detail}. " \
|
||||
"Request: From=#{form['From']} To=#{form['To']}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sms
|
||||
module Providers
|
||||
# Thin wrapper around the Twilio Messages API.
|
||||
#
|
||||
# Docs: https://www.twilio.com/docs/messaging/api/message-resource
|
||||
#
|
||||
# Request shape:
|
||||
# POST https://api.twilio.com/2010-04-01/Accounts/<sid>/Messages.json
|
||||
# Authorization: Basic base64(AccountSid:AuthToken)
|
||||
# Content-Type: application/x-www-form-urlencoded -- NOT JSON
|
||||
# From=+15551234567&To=+15555550100&Body=...
|
||||
#
|
||||
# Response: 201 Created on success with JSON { sid, status, error_code,
|
||||
# error_message, ... }. Treat a 201 with a non-null error_code as failure.
|
||||
class Twilio
|
||||
ENDPOINT_HOST = 'api.twilio.com'
|
||||
TIMEOUT_SECONDS = 15
|
||||
|
||||
def self.configured?(config)
|
||||
config['twilio_account_sid'].to_s.present? &&
|
||||
config['twilio_auth_token'].to_s.present? &&
|
||||
config['twilio_from'].to_s.present?
|
||||
end
|
||||
|
||||
def initialize(config)
|
||||
@sid = config['twilio_account_sid'].to_s.strip
|
||||
@token = config['twilio_auth_token'].to_s.strip
|
||||
@from = format_e164(config['twilio_from'])
|
||||
end
|
||||
|
||||
def deliver(to:, text:, webhook: nil)
|
||||
form = {
|
||||
'From' => @from,
|
||||
'To' => format_e164(to),
|
||||
'Body' => text.to_s
|
||||
}
|
||||
form['StatusCallback'] = webhook if webhook.present?
|
||||
|
||||
response = http_post(form)
|
||||
parse_response!(response, form)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_e164(raw)
|
||||
"+#{Sms.normalize_phone(raw)}"
|
||||
end
|
||||
|
||||
def http_post(form)
|
||||
uri = URI("https://#{ENDPOINT_HOST}/2010-04-01/Accounts/#{@sid}/Messages.json")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = TIMEOUT_SECONDS
|
||||
http.open_timeout = TIMEOUT_SECONDS
|
||||
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
request.basic_auth(@sid, @token)
|
||||
request['Accept'] = 'application/json'
|
||||
request.set_form_data(form)
|
||||
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
def parse_response!(response, form)
|
||||
body = begin
|
||||
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
{ 'raw' => response.body.to_s }
|
||||
end
|
||||
|
||||
if response.is_a?(Net::HTTPSuccess) && body['error_code'].nil?
|
||||
return body
|
||||
end
|
||||
|
||||
code = body['code'] || body['error_code']
|
||||
message = body['message'] || body['error_message'] || body['raw'] || "HTTP #{response.code}"
|
||||
detail = code ? "#{code} #{message}" : message
|
||||
raise Sms::ProviderError,
|
||||
"Twilio rejected request (HTTP #{response.code}): #{detail}. " \
|
||||
"Request: From=#{form['From']} To=#{form['To']}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sms
|
||||
module Providers
|
||||
# Thin wrapper around the VoIP.ms REST/JSON API sendSMS method.
|
||||
#
|
||||
# Docs: https://voip.ms/m/apidocs.php
|
||||
# SMS wiki: https://wiki.voip.ms/article/SMS-MMS
|
||||
#
|
||||
# Request shape:
|
||||
# GET https://voip.ms/api/v1/rest.php
|
||||
# ?api_username=...&api_password=...&method=sendSMS&did=...&dst=...&message=...
|
||||
#
|
||||
# Response: ALWAYS HTTP 200. Success body { "status": "success", "sms": <id> }.
|
||||
# Failure body { "status": "<error_code>" } where error codes include
|
||||
# invalid_credentials, invalid_did, invalid_dst, missing_message,
|
||||
# sms_toolong, limit_reached, ip_not_authorized. Must inspect the `status`
|
||||
# field — HTTP code alone is meaningless.
|
||||
class Voipms
|
||||
ENDPOINT = 'https://voip.ms/api/v1/rest.php'
|
||||
TIMEOUT_SECONDS = 15
|
||||
MAX_SMS_LENGTH = 160
|
||||
|
||||
def self.configured?(config)
|
||||
config['voipms_api_username'].to_s.present? &&
|
||||
config['voipms_api_password'].to_s.present? &&
|
||||
config['voipms_did'].to_s.present?
|
||||
end
|
||||
|
||||
def initialize(config)
|
||||
@username = config['voipms_api_username'].to_s.strip
|
||||
@password = config['voipms_api_password'].to_s.strip
|
||||
@did = Sms.normalize_phone(config['voipms_did'])
|
||||
end
|
||||
|
||||
def deliver(to:, text:, webhook: nil) # rubocop:disable Lint/UnusedMethodArgument
|
||||
message = text.to_s
|
||||
if message.bytesize > MAX_SMS_LENGTH
|
||||
raise Sms::ProviderError,
|
||||
"VoIP.ms rejects messages longer than #{MAX_SMS_LENGTH} bytes; got #{message.bytesize}."
|
||||
end
|
||||
|
||||
params = {
|
||||
'api_username' => @username,
|
||||
'api_password' => @password,
|
||||
'method' => 'sendSMS',
|
||||
'did' => @did,
|
||||
'dst' => Sms.normalize_phone(to),
|
||||
'message' => message
|
||||
}
|
||||
|
||||
response = http_get(params)
|
||||
parse_response!(response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def http_get(params)
|
||||
uri = URI(ENDPOINT)
|
||||
uri.query = URI.encode_www_form(params)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = TIMEOUT_SECONDS
|
||||
http.open_timeout = TIMEOUT_SECONDS
|
||||
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
request['Accept'] = 'application/json'
|
||||
|
||||
http.request(request)
|
||||
end
|
||||
|
||||
def parse_response!(response)
|
||||
body = begin
|
||||
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
||||
rescue JSON::ParserError
|
||||
{ 'raw' => response.body.to_s }
|
||||
end
|
||||
|
||||
status = body['status'].to_s
|
||||
return body if response.is_a?(Net::HTTPSuccess) && status == 'success'
|
||||
|
||||
detail = status.presence || body['raw'].presence || "HTTP #{response.code}"
|
||||
raise Sms::ProviderError, "VoIP.ms rejected request: #{detail}."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue