Merge branch 'sms-twilio-voipms-signalwire'

Brings the three new SMS providers (Twilio, VoIP.ms, SignalWire) up
alongside the existing BulkVS integration. Per-account credentials
live in the encrypted sms_configs hash, namespaced by provider; the
provider select on /settings/sms drives a per-provider field block.

Clean merge — no conflicts with the post-DocuSeal-3.0.0 master.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/687/head
Wabo 1 month ago
commit 3b1003eb9f

@ -1,6 +1,6 @@
# SMS # SMS
WaboSign sends signing-invitation SMS via configurable providers. v1 ships [BulkVS](https://www.bulkvs.com) only; the architecture leaves room for additional providers behind the same `Sms.send_message` interface. WaboSign sends signing-invitation SMS via configurable providers. Supported providers: [BulkVS](https://www.bulkvs.com), [Twilio](https://www.twilio.com), [VoIP.ms](https://voip.ms), and [SignalWire](https://signalwire.com). All providers share the same `Sms.send_message` interface; per-account credentials live in the encrypted `sms_configs` blob and are namespaced by provider.
## What you get ## What you get
@ -17,13 +17,56 @@ In the [BulkVS portal](https://portal.bulkvs.com/), open the **API** tab and cop
In WaboSign: In WaboSign:
1. Sign in as an admin → **Settings****SMS**. 1. Sign in as an admin → **Settings****SMS**.
2. Toggle **Enable SMS** on. 2. Toggle **Enable SMS** on; choose **BulkVS** from the provider dropdown.
3. Paste the BulkVS Basic Auth token. 3. Paste the BulkVS Basic Auth token.
4. Set **From Number** in E.164 (digits-only with country code, e.g. `15551234567`). 4. Set **From Number** in E.164 (digits-only with country code, e.g. `15551234567`).
5. *(Optional)* set the **Delivery Status Webhook** to a URL BulkVS will POST status events to. WaboSign does not yet process these inbound events — the field is stored on the config and forwarded to BulkVS so the receipts flow somewhere of your choosing. 5. *(Optional)* set the **Delivery Status Webhook** to a URL BulkVS will POST status events to. WaboSign does not yet process these inbound events — the field is stored on the config and forwarded to BulkVS so the receipts flow somewhere of your choosing.
6. Save. 6. Save.
7. Use the **Send test** card to verify with a number you own. Errors from BulkVS (bad credentials, malformed number, etc.) come back as `Sms::ProviderError` and are shown inline. 7. Use the **Send test** card to verify with a number you own. Errors from BulkVS (bad credentials, malformed number, etc.) come back as `Sms::ProviderError` and are shown inline.
## Configuring Twilio
In the [Twilio Console](https://console.twilio.com/), open **Account Info** and copy the **Account SID** and **Auth Token** (click *show* to reveal). Buy an SMS-capable number under **Phone Numbers → Manage**. For US long-code delivery you must complete **A2P 10DLC** brand + campaign registration before messages will deliver.
In WaboSign:
1. **Settings****SMS**; pick **Twilio** from the provider dropdown.
2. Paste the Account SID, Auth Token, and Twilio number (full E.164 with leading `+`, e.g. `+15551234567`).
3. Save, then use **Send test**. Twilio returns a message SID on success; a non-null `error_code` is reported as `Sms::ProviderError`.
## Configuring VoIP.ms
Set up at the [API portal](https://voip.ms/m/api.php):
1. Set a dedicated **API password** (distinct from your portal login password).
2. Toggle **Enable API** on.
3. Add the WaboSign server's egress IP to the **API IP whitelist**. Without this every call returns `ip_not_authorized` or `invalid_credentials`.
4. Under **Manage DIDs**, enable the **SMS** feature on the DID you plan to send from.
In WaboSign:
1. **Settings****SMS**; pick **VoIP.ms** from the provider dropdown.
2. Fill in your portal-login email (API Username), the API password from step 1 above, and the SMS-enabled DID (digits only, no `+`).
3. Save, then **Send test**.
Caveats:
- The API hard-caps each call at **160 bytes**, with no segmentation. WaboSign rejects longer bodies up front (`Sms::ProviderError`) rather than truncating.
- VoIP.ms returns HTTP 200 on every error, with `status` indicating the failure code; common codes (`invalid_credentials`, `limit_reached`, `sms_toolong`, `ip_not_authorized`) surface verbatim in the error message.
- Default account quota is 100 SMS/day — contact VoIP.ms support to raise it.
## Configuring SignalWire
Open your [SignalWire dashboard](https://signalwire.com/), click the **API** tab in the sidebar, and copy:
- **Your Space URL** (e.g. `acme.signalwire.com`).
- **Your Project ID** (UUID).
- **Your API Token** (`PT…`). Make sure the token has the **Messaging** scope enabled.
Buy an SMS-capable number under **Phone Numbers**. For US long-code delivery you must attach the number to an approved **10DLC / TCR** campaign.
In WaboSign:
1. **Settings****SMS**; pick **SignalWire** from the provider dropdown.
2. Enter the Space URL (omit `https://`), Project ID, API Token, and From Number (E.164 with leading `+`).
3. Save, then **Send test**.
A 401 with no obvious credential mistake usually means the API Token lacks the Messaging scope — the upstream message is surfaced verbatim, so check the flash text.
## How sending happens ## How sending happens
``` ```
@ -44,25 +87,30 @@ Body substitution runs through the existing [`ReplaceEmailVariables`](lib/replac
## Adding another provider ## Adding another provider
Two-step extension: Three-step extension:
1. **Implement the provider class** at `lib/sms/providers/<name>.rb`. The interface is `#new(config)` + `#deliver(to:, text:, webhook: nil)`. Raise `Sms::ProviderError` on non-2xx responses. See [`lib/sms/providers/bulkvs.rb`](lib/sms/providers/bulkvs.rb) for shape. 1. **Implement the provider class** at `lib/sms/providers/<name>.rb`. The interface is `self.configured?(config)` + `#new(config)` + `#deliver(to:, text:, webhook: nil)`. Raise `Sms::ProviderError` on non-2xx responses (or on logical failures hidden behind a 200, as VoIP.ms does). See [`lib/sms/providers/bulkvs.rb`](lib/sms/providers/bulkvs.rb), [`twilio.rb`](lib/sms/providers/twilio.rb), [`voipms.rb`](lib/sms/providers/voipms.rb), and [`signalwire.rb`](lib/sms/providers/signalwire.rb) for shape.
2. **Register the provider** in three places: 2. **Register the provider** in three places:
- `Sms::SUPPORTED_PROVIDERS` in [`lib/sms.rb`](lib/sms.rb). - Append to `Sms::SUPPORTED_PROVIDERS` in [`lib/sms.rb`](lib/sms.rb).
- The `case provider` switch in `Sms.send_message`. - Add a branch to `Sms.provider_class` in the same file.
- The `<select>` of provider options in [`app/views/sms_settings/index.html.erb`](app/views/sms_settings/index.html.erb). - Add a `data-provider-block="..."` section to [`app/views/sms_settings/index.html.erb`](app/views/sms_settings/index.html.erb) with the credential fields, and add the human-readable label to the `provider_labels` hash at the top of the template.
3. **If the new provider has a secret field** that should be preserved on blank-edits (the way BulkVS's Basic Auth token is), add its config key to `SmsSettingsController::SECRET_KEYS`.
Per-provider config fields (e.g. Twilio's account SID + auth token) can ride on the same `sms_configs` EncryptedConfig hash — pick names that don't collide with BulkVS's keys, and have the view render the right field for the selected provider. The Sidekiq job and per-submitter controller are provider-agnostic and don't need to change. Per-provider config fields all ride on the same `sms_configs` EncryptedConfig hash; prefix new keys with the provider name (e.g. `twilio_auth_token`) to avoid collisions. The Sidekiq job and per-submitter controller are provider-agnostic and don't need to change.
## Code map ## Code map
| File | Role | | File | Role |
|---|---| |---|---|
| [lib/sms.rb](lib/sms.rb) | Top-level `Sms` module: `enabled_for?(account)`, `configuration_for(account)`, `send_message`, `normalize_phone`. Error types: `Sms::Error`, `Sms::NotConfiguredError`, `Sms::ProviderError`, `Sms::InvalidNumberError`. | | [lib/sms.rb](lib/sms.rb) | Top-level `Sms` module: `enabled_for?(account)`, `configuration_for(account)`, `send_message`, `normalize_phone`, `provider_class`. Error types: `Sms::Error`, `Sms::NotConfiguredError`, `Sms::ProviderError`, `Sms::InvalidNumberError`. |
| [lib/sms/providers/bulkvs.rb](lib/sms/providers/bulkvs.rb) | BulkVS HTTPS client. Constructs the Basic Auth + JSON body, raises `Sms::ProviderError` with the upstream error message on non-2xx. | | [lib/sms/providers/bulkvs.rb](lib/sms/providers/bulkvs.rb) | BulkVS HTTPS client. Constructs Basic Auth + JSON body, raises `Sms::ProviderError` with the upstream error message on non-2xx. |
| [lib/sms/providers/twilio.rb](lib/sms/providers/twilio.rb) | Twilio Messages API client (form-encoded body, Basic Auth with SID:Token, 201 on success). |
| [lib/sms/providers/voipms.rb](lib/sms/providers/voipms.rb) | VoIP.ms REST/JSON `sendSMS` client (GET with query-string auth, treats `status != "success"` as failure even on HTTP 200, enforces 160-byte cap up front). |
| [lib/sms/providers/signalwire.rb](lib/sms/providers/signalwire.rb) | SignalWire Compatibility API client (Twilio-shaped form body, per-account Space URL host, requires Messaging-scoped API Token). |
| [app/jobs/send_submitter_invitation_sms_job.rb](app/jobs/send_submitter_invitation_sms_job.rb) | Sidekiq job. Skips if submitter has no phone, is completed, archived, or the account has no SMS config. | | [app/jobs/send_submitter_invitation_sms_job.rb](app/jobs/send_submitter_invitation_sms_job.rb) | Sidekiq job. Skips if submitter has no phone, is completed, archived, or the account has no SMS config. |
| [app/controllers/sms_settings_controller.rb](app/controllers/sms_settings_controller.rb) | `index` / `create` / `test_message`. Preserves the saved Basic Auth token when the field is left blank on edit. | | [app/controllers/sms_settings_controller.rb](app/controllers/sms_settings_controller.rb) | `index` / `create` / `test_message`. `SECRET_KEYS` lists the password-field config keys that should be preserved on blank-edits. |
| [app/controllers/submitters_send_sms_controller.rb](app/controllers/submitters_send_sms_controller.rb) | `create` action behind the per-submitter Send SMS button. Mirrors `SubmittersSendEmailController`. | | [app/controllers/submitters_send_sms_controller.rb](app/controllers/submitters_send_sms_controller.rb) | `create` action behind the per-submitter Send SMS button. Mirrors `SubmittersSendEmailController`. |
| [app/views/sms_settings/index.html.erb](app/views/sms_settings/index.html.erb) | Settings form + test-send card. | | [app/views/sms_settings/index.html.erb](app/views/sms_settings/index.html.erb) | Settings form + test-send card. |
| [app/views/submissions/_send_sms_button.html.erb](app/views/submissions/_send_sms_button.html.erb) | Per-submitter Send SMS button. Disabled with a tooltip when the provider is unconfigured or the submitter has no phone. | | [app/views/submissions/_send_sms_button.html.erb](app/views/submissions/_send_sms_button.html.erb) | Per-submitter Send SMS button. Disabled with a tooltip when the provider is unconfigured or the submitter has no phone. |
@ -72,6 +120,15 @@ Per-provider config fields (e.g. Twilio's account SID + auth token) can ride on
| [app/models/account_config.rb](app/models/account_config.rb) | `SUBMITTER_INVITATION_SMS_KEY = 'submitter_invitation_sms'`. | | [app/models/account_config.rb](app/models/account_config.rb) | `SUBMITTER_INVITATION_SMS_KEY = 'submitter_invitation_sms'`. |
| [config/routes.rb](config/routes.rb) | `resources :sms` with `index`, `create`, and `post :test_message` on the collection; submitters nested `resources :send_sms`. | | [config/routes.rb](config/routes.rb) | `resources :sms` with `index`, `create`, and `post :test_message` on the collection; submitters nested `resources :send_sms`. |
## Provider wire-format quick reference
| Provider | Endpoint | Body encoding | Phone format | Success signal |
|---|---|---|---|---|
| BulkVS | POST `https://portal.bulkvs.com/api/v1.0/messageSend` | JSON | digits, no `+` | HTTP 2xx |
| Twilio | POST `https://api.twilio.com/2010-04-01/Accounts/<SID>/Messages.json` | `application/x-www-form-urlencoded` | digits **with** `+` | HTTP 201 **and** `error_code` is null |
| VoIP.ms | GET `https://voip.ms/api/v1/rest.php?method=sendSMS&…` | query string | digits, no `+` | HTTP 200 **and** `status == "success"` |
| SignalWire | POST `https://<space>/api/laml/2010-04-01/Accounts/<ProjectID>/Messages` | `application/x-www-form-urlencoded` | digits **with** `+` | HTTP 201 **and** `error_code` is null |
## BulkVS API reference ## BulkVS API reference
- Endpoint: `POST https://portal.bulkvs.com/api/v1.0/messageSend` - Endpoint: `POST https://portal.bulkvs.com/api/v1.0/messageSend`
@ -96,9 +153,10 @@ Per-provider config fields (e.g. Twilio's account SID + auth token) can ride on
- With a bogus saved token, `Sms.send_message` opens an HTTPS connection to BulkVS, receives a **real 401**, and surfaces it as `Sms::ProviderError("BulkVS rejected request (HTTP 401): …")`. Proves the transport + auth header + JSON body shape are correct end-to-end; only the test token is wrong. - With a bogus saved token, `Sms.send_message` opens an HTTPS connection to BulkVS, receives a **real 401**, and surfaces it as `Sms::ProviderError("BulkVS rejected request (HTTP 401): …")`. Proves the transport + auth header + JSON body shape are correct end-to-end; only the test token is wrong.
- Route helpers resolve: `settings_sms_path` (no `_index_` because "sms" is uncountable in Rails inflection), `test_message_settings_sms_path`, `submitter_send_sms_path`. - Route helpers resolve: `settings_sms_path` (no `_index_` because "sms" is uncountable in Rails inflection), `test_message_settings_sms_path`, `submitter_send_sms_path`.
## Out of scope for v1 ## Out of scope
- **No inbound delivery webhook handler.** The URL is stored and forwarded to BulkVS, but WaboSign does not yet consume the resulting POSTs. Add a controller at e.g. `app/controllers/webhooks/sms_delivery_controller.rb` if you want delivery confirmations in the audit trail or `SubmissionEvent` log. - **No inbound delivery-status webhook handler.** BulkVS / Twilio / SignalWire can be told where to POST receipts (`delivery_status_webhook_url` / `StatusCallback`), but WaboSign does not yet consume those POSTs. Add a controller at e.g. `app/controllers/webhooks/sms_delivery_controller.rb` if you want delivery confirmations in the audit trail or `SubmissionEvent` log.
- **No other SMS providers.** `Sms::SUPPORTED_PROVIDERS = %w[bulkvs]` is the gate; see "Adding another provider" above. - **No MMS.** Outbound is text-only across all providers.
- **Phone validation is minimal**`Sms.normalize_phone` strips non-digits and rejects strings shorter than 8 digits. Malformed E.164 is caught by BulkVS and surfaces as `Sms::ProviderError` with the upstream message. - **No client-side message segmentation.** BulkVS / Twilio / SignalWire auto-segment on their end and bill per segment; VoIP.ms refuses bodies over 160 bytes outright — WaboSign raises `Sms::ProviderError` up front for that provider instead of attempting to split.
- **No rate-limiting / per-account quota.** Relies on BulkVS's own controls. - **Phone validation is minimal**`Sms.normalize_phone` strips non-digits and rejects strings shorter than 8 digits. Each provider prepends `+` if its wire format requires it. Malformed numbers surface as `Sms::ProviderError` with the upstream message.
- **No rate-limiting / per-account quota.** Relies on each provider's own controls.

@ -46,14 +46,18 @@ class SmsSettingsController < ApplicationController
key: EncryptedConfig::SMS_CONFIGS_KEY) key: EncryptedConfig::SMS_CONFIGS_KEY)
end end
SECRET_KEYS = %w[basic_auth_token twilio_auth_token voipms_api_password signalwire_api_token].freeze
def build_sms_value def build_sms_value
submitted = params.require(:encrypted_config).permit(value: {})[:value].to_h submitted = params.require(:encrypted_config).permit(value: {})[:value].to_h
existing = @encrypted_config.value || {} existing = @encrypted_config.value || {}
# Preserve the saved Basic Auth token when the field is left blank # Password fields are rendered without their saved value, so an unedited
# (the form never echoes it back, so an unedited submit would otherwise # submit posts back an empty string. Preserve the saved secret in that case
# wipe it out). # so users can edit unrelated fields without re-pasting credentials.
submitted['basic_auth_token'] = existing['basic_auth_token'] if submitted['basic_auth_token'].to_s.empty? SECRET_KEYS.each do |key|
submitted[key] = existing[key] if submitted[key].to_s.empty?
end
submitted['enabled'] = submitted['enabled'].to_s == '1' || submitted['enabled'].to_s == 'true' submitted['enabled'] = submitted['enabled'].to_s == '1' || submitted['enabled'].to_s == 'true'
submitted['provider'] = (submitted['provider'].presence || 'bulkvs').to_s submitted['provider'] = (submitted['provider'].presence || 'bulkvs').to_s

@ -5,6 +5,21 @@
<% value = @encrypted_config.value || {} %> <% value = @encrypted_config.value || {} %>
<% sms_live = Sms.enabled_for?(current_account) %> <% sms_live = Sms.enabled_for?(current_account) %>
<%
provider_labels = {
'bulkvs' => 'BulkVS',
'twilio' => 'Twilio',
'voipms' => 'VoIP.ms',
'signalwire' => 'SignalWire'
}
sending_number = case value['provider'].to_s
when 'twilio' then value['twilio_from']
when 'voipms' then value['voipms_did']
when 'signalwire' then value['signalwire_from']
else value['from_number']
end
selected_provider = value['provider'].presence || 'bulkvs'
%>
<% if sms_live %> <% if sms_live %>
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
@ -12,8 +27,8 @@
<div> <div>
<p class="font-bold">SMS is enabled</p> <p class="font-bold">SMS is enabled</p>
<p class="text-gray-700"> <p class="text-gray-700">
Provider: <code><%= value['provider'].to_s.upcase %></code>. Provider: <code><%= provider_labels[value['provider'].to_s] || value['provider'].to_s.upcase %></code>.
From: <code><%= value['from_number'] %></code>. From: <code><%= sending_number %></code>.
</p> </p>
</div> </div>
</div> </div>
@ -23,13 +38,18 @@
<div> <div>
<p class="font-bold">SMS provider is not configured</p> <p class="font-bold">SMS provider is not configured</p>
<p class="text-gray-700"> <p class="text-gray-700">
WaboSign currently supports <a href="https://www.bulkvs.com/" target="_blank" rel="noopener" class="link">BulkVS</a>. Paste the Basic Auth header value from the BulkVS portal below. WaboSign supports
<a href="https://www.bulkvs.com/" target="_blank" rel="noopener" class="link">BulkVS</a>,
<a href="https://www.twilio.com/" target="_blank" rel="noopener" class="link">Twilio</a>,
<a href="https://voip.ms/" target="_blank" rel="noopener" class="link">VoIP.ms</a>, and
<a href="https://signalwire.com/" target="_blank" rel="noopener" class="link">SignalWire</a>.
Pick a provider below and paste its credentials.
</p> </p>
</div> </div>
</div> </div>
<% end %> <% end %>
<%= form_for @encrypted_config, url: settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= form_for @encrypted_config, url: settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4', id: 'sms_settings_form' } do |f| %>
<%= f.fields_for :value do |ff| %> <%= f.fields_for :value do |ff| %>
<div class="form-control"> <div class="form-control">
<label class="label cursor-pointer" for="encrypted_config_value_enabled"> <label class="label cursor-pointer" for="encrypted_config_value_enabled">
@ -39,11 +59,17 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :provider, 'Provider', class: 'label' %> <%= ff.label :provider, 'Provider', class: 'label' %>
<%= ff.select :provider, [%w[BulkVS bulkvs]], { selected: value['provider'] || 'bulkvs' }, class: 'base-select' %> <%= ff.select :provider,
Sms::SUPPORTED_PROVIDERS.map { |p| [provider_labels[p] || p, p] },
{ selected: selected_provider },
class: 'base-select',
id: 'sms_provider_select' %>
</div> </div>
<div data-provider-block="bulkvs" class="space-y-4<%= ' hidden' unless selected_provider == 'bulkvs' %>">
<div class="form-control"> <div class="form-control">
<%= ff.label :basic_auth_token, 'BulkVS Basic Auth Token', class: 'label' %> <%= ff.label :basic_auth_token, 'BulkVS Basic Auth Token', class: 'label' %>
<%= ff.password_field :basic_auth_token, class: 'base-input', placeholder: value['basic_auth_token'].present? ? '*************' : 'Paste from BulkVS portal' %> <%= ff.password_field :basic_auth_token, value: '', class: 'base-input', placeholder: value['basic_auth_token'].present? ? '*************' : 'Paste from BulkVS portal' %>
<% if value['basic_auth_token'].present? %> <% if value['basic_auth_token'].present? %>
<span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved token.</span> <span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved token.</span>
<% else %> <% else %>
@ -60,6 +86,78 @@
<%= ff.url_field :delivery_webhook_url, value: value['delivery_webhook_url'], class: 'base-input', placeholder: 'https://your-app.example/webhooks/sms' %> <%= ff.url_field :delivery_webhook_url, value: value['delivery_webhook_url'], class: 'base-input', placeholder: 'https://your-app.example/webhooks/sms' %>
<span class="label-text-alt mt-1 opacity-70">If set, BulkVS will POST delivery-status events here for each message.</span> <span class="label-text-alt mt-1 opacity-70">If set, BulkVS will POST delivery-status events here for each message.</span>
</div> </div>
</div>
<div data-provider-block="twilio" class="space-y-4<%= ' hidden' unless selected_provider == 'twilio' %>">
<div class="form-control">
<%= ff.label :twilio_account_sid, 'Twilio Account SID', class: 'label' %>
<%= ff.text_field :twilio_account_sid, value: value['twilio_account_sid'], class: 'base-input', placeholder: 'AC...' %>
<span class="label-text-alt mt-1 opacity-70">From your Twilio Console "Account Info" panel.</span>
</div>
<div class="form-control">
<%= ff.label :twilio_auth_token, 'Twilio Auth Token', class: 'label' %>
<%= ff.password_field :twilio_auth_token, value: '', class: 'base-input', placeholder: value['twilio_auth_token'].present? ? '*************' : 'Click "show" in the Console to reveal' %>
<% if value['twilio_auth_token'].present? %>
<span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved token.</span>
<% else %>
<span class="label-text-alt mt-1 opacity-70">Found next to the Account SID in the Twilio Console.</span>
<% end %>
</div>
<div class="form-control">
<%= ff.label :twilio_from, 'From Number', class: 'label' %>
<%= ff.text_field :twilio_from, value: value['twilio_from'], class: 'base-input', placeholder: '+15551234567' %>
<span class="label-text-alt mt-1 opacity-70">Twilio number purchased in <strong>Phone Numbers → Manage</strong>. Use full E.164 with leading <code>+</code>.</span>
</div>
</div>
<div data-provider-block="voipms" class="space-y-4<%= ' hidden' unless selected_provider == 'voipms' %>">
<div class="form-control">
<%= ff.label :voipms_api_username, 'API Username', class: 'label' %>
<%= ff.text_field :voipms_api_username, value: value['voipms_api_username'], class: 'base-input', placeholder: 'your-account@example.com' %>
<span class="label-text-alt mt-1 opacity-70">Your VoIP.ms portal login email.</span>
</div>
<div class="form-control">
<%= ff.label :voipms_api_password, 'API Password', class: 'label' %>
<%= ff.password_field :voipms_api_password, value: '', class: 'base-input', placeholder: value['voipms_api_password'].present? ? '*************' : 'Set this at voip.ms/m/api.php' %>
<% if value['voipms_api_password'].present? %>
<span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved password.</span>
<% else %>
<span class="label-text-alt mt-1 opacity-70">Set the dedicated <strong>API password</strong> at <a href="https://voip.ms/m/api.php" target="_blank" rel="noopener" class="link">voip.ms/m/api.php</a> — this is <em>not</em> your portal login password. On the same page, enable API access and whitelist this server's egress IP, or every call will fail with <code>ip_not_authorized</code>.</span>
<% end %>
</div>
<div class="form-control">
<%= ff.label :voipms_did, 'DID (Sending Number)', class: 'label' %>
<%= ff.text_field :voipms_did, value: value['voipms_did'], class: 'base-input', placeholder: '5551234567' %>
<span class="label-text-alt mt-1 opacity-70">An SMS-enabled DID from <strong>Manage DIDs</strong>. Digits only, no <code>+</code>. The DID must have the SMS feature enabled.</span>
</div>
</div>
<div data-provider-block="signalwire" class="space-y-4<%= ' hidden' unless selected_provider == 'signalwire' %>">
<div class="form-control">
<%= ff.label :signalwire_space_url, 'Space URL', class: 'label' %>
<%= ff.text_field :signalwire_space_url, value: value['signalwire_space_url'], class: 'base-input', placeholder: 'yourname.signalwire.com' %>
<span class="label-text-alt mt-1 opacity-70">From <strong>Dashboard → API</strong>. Omit <code>https://</code>.</span>
</div>
<div class="form-control">
<%= ff.label :signalwire_project_id, 'Project ID', class: 'label' %>
<%= ff.text_field :signalwire_project_id, value: value['signalwire_project_id'], class: 'base-input', placeholder: '00000000-0000-0000-0000-000000000000' %>
<span class="label-text-alt mt-1 opacity-70">The UUID labelled "Your Project ID" on the API tab.</span>
</div>
<div class="form-control">
<%= ff.label :signalwire_api_token, 'API Token', class: 'label' %>
<%= ff.password_field :signalwire_api_token, value: '', class: 'base-input', placeholder: value['signalwire_api_token'].present? ? '*************' : 'PT...' %>
<% if value['signalwire_api_token'].present? %>
<span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved token.</span>
<% else %>
<span class="label-text-alt mt-1 opacity-70">Generate on the API tab. The token must have the <strong>Messaging</strong> scope enabled or sends return 401.</span>
<% end %>
</div>
<div class="form-control">
<%= ff.label :signalwire_from, 'From Number', class: 'label' %>
<%= ff.text_field :signalwire_from, value: value['signalwire_from'], class: 'base-input', placeholder: '+15551234567' %>
<span class="label-text-alt mt-1 opacity-70">A SignalWire number from <strong>Phone Numbers</strong>. Full E.164 with leading <code>+</code>.</span>
</div>
</div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
@ -86,3 +184,19 @@
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>
<%= javascript_tag nonce: true do %>
(function () {
const select = document.getElementById('sms_provider_select')
if (!select) return
const blocks = document.querySelectorAll('[data-provider-block]')
const sync = () => {
const current = select.value
blocks.forEach((block) => {
block.classList.toggle('hidden', block.dataset.providerBlock !== current)
})
}
select.addEventListener('change', sync)
sync()
})()
<% end %>

@ -6,13 +6,13 @@ module Sms
class ProviderError < Error; end class ProviderError < Error; end
class InvalidNumberError < Error; end class InvalidNumberError < Error; end
SUPPORTED_PROVIDERS = %w[bulkvs].freeze SUPPORTED_PROVIDERS = %w[bulkvs twilio voipms signalwire].freeze
module_function module_function
# Returns the SMS configuration hash for an account, with the same keys the # Returns the SMS configuration hash for an account. Keys vary by provider —
# form posts: { provider, enabled, basic_auth_token, from_number, # see the per-provider class for which keys it consumes. Returns nil if no
# delivery_webhook_url }. Returns nil if no record exists. # record exists.
def configuration_for(account) def configuration_for(account)
return nil if account.nil? return nil if account.nil?
@ -22,42 +22,49 @@ module Sms
def enabled_for?(account) def enabled_for?(account)
config = configuration_for(account) config = configuration_for(account)
config.is_a?(Hash) && return false unless config.is_a?(Hash)
config['enabled'] && return false unless config['enabled']
SUPPORTED_PROVIDERS.include?(config['provider'].to_s) &&
config['basic_auth_token'].to_s.present? && klass = provider_class(config['provider'].to_s)
config['from_number'].to_s.present? klass ? klass.configured?(config) : false
end end
# Send an SMS via the account's configured provider. # Send an SMS via the account's configured provider.
# #
# account: a WaboSign Account record # account: a WaboSign Account record
# to: the recipient phone number (E.164 string, leading + tolerated) # to: the recipient phone number (E.164, leading + tolerated; the
# provider class decides whether to keep or strip the +)
# text: the message body (already variable-substituted) # text: the message body (already variable-substituted)
# webhook: optional override of the per-message delivery_status_webhook_url # webhook: optional override of the per-message delivery callback URL
# #
# Returns the provider's parsed JSON response on success. Raises # Returns the provider's parsed response on success. Raises
# NotConfiguredError or ProviderError on failure. # NotConfiguredError or ProviderError on failure.
def send_message(account:, to:, text:, webhook: nil) def send_message(account:, to:, text:, webhook: nil)
config = configuration_for(account)
raise NotConfiguredError, 'SMS provider is not configured' unless enabled_for?(account) raise NotConfiguredError, 'SMS provider is not configured' unless enabled_for?(account)
provider = config['provider'].to_s config = configuration_for(account)
case provider klass = provider_class(config['provider'].to_s)
when 'bulkvs' raise NotConfiguredError, "Unsupported SMS provider: #{config['provider'].inspect}" unless klass
Sms::Providers::Bulkvs.new(config).deliver(to: to, text: text, webhook: webhook)
else klass.new(config).deliver(to: to, text: text, webhook: webhook)
raise NotConfiguredError, "Unsupported SMS provider: #{provider.inspect}"
end
end end
# Normalize a phone number to E.164 (digits-only, no '+'). BulkVS expects # Normalize a phone number to digits-only. Each provider class is responsible
# eleven-digit US numbers like 15551234567; international numbers are passed # for prepending '+' if its wire format requires E.164 with leading plus
# through as-is once stripped of formatting characters. # (Twilio, SignalWire); BulkVS and VoIP.ms take digits-only.
def normalize_phone(raw) def normalize_phone(raw)
digits = raw.to_s.gsub(/[^\d]/, '') digits = raw.to_s.gsub(/[^\d]/, '')
raise InvalidNumberError, "Invalid phone number: #{raw.inspect}" if digits.length < 8 raise InvalidNumberError, "Invalid phone number: #{raw.inspect}" if digits.length < 8
digits digits
end end
def provider_class(name)
case name
when 'bulkvs' then Sms::Providers::Bulkvs
when 'twilio' then Sms::Providers::Twilio
when 'voipms' then Sms::Providers::Voipms
when 'signalwire' then Sms::Providers::Signalwire
end
end
end end

@ -19,6 +19,10 @@ module Sms
ENDPOINT = 'https://portal.bulkvs.com/api/v1.0/messageSend' ENDPOINT = 'https://portal.bulkvs.com/api/v1.0/messageSend'
TIMEOUT_SECONDS = 15 TIMEOUT_SECONDS = 15
def self.configured?(config)
config['basic_auth_token'].to_s.present? && config['from_number'].to_s.present?
end
def initialize(config) def initialize(config)
@token = config['basic_auth_token'].to_s.strip @token = config['basic_auth_token'].to_s.strip
@from = Sms.normalize_phone(config['from_number']) @from = Sms.normalize_phone(config['from_number'])

@ -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…
Cancel
Save