smsru_ruby 1.0.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/.yardopts +6 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +376 -0
- data/lib/sms_ru/auth.rb +21 -0
- data/lib/sms_ru/call_check.rb +32 -0
- data/lib/sms_ru/callbacks.rb +38 -0
- data/lib/sms_ru/client.rb +190 -0
- data/lib/sms_ru/coerce.rb +51 -0
- data/lib/sms_ru/data.rb +263 -0
- data/lib/sms_ru/errors.rb +35 -0
- data/lib/sms_ru/events.rb +65 -0
- data/lib/sms_ru/my.rb +29 -0
- data/lib/sms_ru/statuses.rb +50 -0
- data/lib/sms_ru/stoplist.rb +43 -0
- data/lib/sms_ru/version.rb +6 -0
- data/lib/sms_ru/webhook.rb +74 -0
- data/lib/smsru_ruby.rb +19 -0
- data/sig/manifest.yaml +9 -0
- data/sig/sms_ru/auth.rbs +9 -0
- data/sig/sms_ru/call_check.rbs +10 -0
- data/sig/sms_ru/callbacks.rbs +15 -0
- data/sig/sms_ru/client.rbs +53 -0
- data/sig/sms_ru/coerce.rbs +15 -0
- data/sig/sms_ru/data.rbs +141 -0
- data/sig/sms_ru/errors.rbs +25 -0
- data/sig/sms_ru/events.rbs +49 -0
- data/sig/sms_ru/my.rbs +12 -0
- data/sig/sms_ru/statuses.rbs +35 -0
- data/sig/sms_ru/stoplist.rbs +11 -0
- data/sig/sms_ru/version.rbs +3 -0
- data/sig/sms_ru/webhook.rbs +17 -0
- data/smsru_ruby.gemspec +29 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d8d52bb804fe21a23adffdcc6a2a5cea8ca864042f517b9f1258e95688d3a48c
|
|
4
|
+
data.tar.gz: f9e9c5cbd4ea6980885b589f07bda2b7f12bbe0dcdff2af559adfe922e4c979d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a091a855d10c251fa1891fb440c89ccaaac9d74105006b2211a1325ac92de301cd68b00198f7e33198a4535417b3d0eddcdf64fc0a9a2f3a53ad36a7cb7d1567
|
|
7
|
+
data.tar.gz: 65fbe47c1243a1580795da2833df5697f628b4c9d10ece5793d1d7ac7214468468c29a9b3d31e805f1e850e9dc752b159515a30d34248bc278f206a9f672582b
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-06-26
|
|
11
|
+
|
|
12
|
+
First public release. A Ruby port of the official SMS.ru PHP library covering the
|
|
13
|
+
same API, reworked to be idiomatic Ruby. How it differs from the original:
|
|
14
|
+
|
|
15
|
+
- **Idiomatic, namespaced API** instead of flat `get_*`/`add_*` methods: account
|
|
16
|
+
reads under `client.my` (`#balance`, `#limit`, `#free_limit`, `#senders`),
|
|
17
|
+
credential check via `client.auth.ok?`, plus `client.stoplist`,
|
|
18
|
+
`client.callbacks`, and `client.callcheck` sub-resources. Keyword arguments for
|
|
19
|
+
every optional send parameter, plus a per-client `from` default.
|
|
20
|
+
- **Typed, immutable `Data` results** that separate *operation outcome* from
|
|
21
|
+
*delivery state*: `#ok?` plus `#error_code`/`#error_text` on rejected
|
|
22
|
+
`Sms`/`CostItem` entries; `#delivered?`/`#pending?`/`#failed?`/`#found?` and
|
|
23
|
+
named `SmsRu::Statuses` constants for the delivery `status_code` on `Status`
|
|
24
|
+
and webhook events; `#ok?`/`#ok`/`#failed` collection helpers on `SendResult`
|
|
25
|
+
and `Cost`; plus `#confirmed?` and `#available_today`. No raw decoded JSON or
|
|
26
|
+
magic numbers.
|
|
27
|
+
- **Typed error hierarchy** under `SmsRu::Error` (`AuthError`,
|
|
28
|
+
`InsufficientFundsError`, `ResponseError`, `ConnectionError`) — errors are
|
|
29
|
+
raised, not returned as status codes you have to inspect.
|
|
30
|
+
- **First-class inbound webhooks**: `SmsRu::Webhook.parse` decodes the callback
|
|
31
|
+
POST into typed events (`SmsRu::Events::SmsStatus`, `CallcheckStatus`, `Test`,
|
|
32
|
+
`Unknown`), and `SmsRu::Webhook.valid?` verifies the signature.
|
|
33
|
+
- **Zero runtime dependencies** (Ruby stdlib only, no curl), TLS verified by
|
|
34
|
+
default, with configurable `timeout`, `retries`, global `test` mode, and an
|
|
35
|
+
optional `logger`.
|
|
36
|
+
- **Ships RBS type signatures** (`sig/`) checked at 100% coverage under Steep's
|
|
37
|
+
strict profile and verified against the test suite at runtime (`rbs test`);
|
|
38
|
+
SMS.ru's loosely-typed JSON is normalized to the declared types at the parse
|
|
39
|
+
boundary, so result objects never surface raw wire values.
|
|
40
|
+
|
|
41
|
+
[Unreleased]: https://github.com/svyatov/smsru_ruby/compare/v1.0.0...HEAD
|
|
42
|
+
[1.0.0]: https://github.com/svyatov/smsru_ruby/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonid Svyatov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# smsru_ruby
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/smsru_ruby)
|
|
4
|
+
[](https://github.com/svyatov/smsru_ruby/actions/workflows/main.yml)
|
|
5
|
+
[](https://codecov.io/gh/svyatov/smsru_ruby)
|
|
6
|
+
[](https://rubydoc.info/gems/smsru_ruby)
|
|
7
|
+
[](https://www.ruby-lang.org)
|
|
8
|
+
[](https://github.com/svyatov/smsru_ruby/tree/main/sig)
|
|
9
|
+
|
|
10
|
+
A modern, **dependency-free**, **fully typed** Ruby client for the [SMS.ru](https://sms.ru) HTTP API —
|
|
11
|
+
typed results, typed errors, shipped RBS signatures, and first-class webhooks.
|
|
12
|
+
|
|
13
|
+
It is a clean, idiomatic Ruby port of the official [SMS.ru PHP library](https://sms.ru/php):
|
|
14
|
+
send single or bulk SMS, schedule delivery, check cost and delivery status, verify
|
|
15
|
+
users by phone call, inspect your balance/limits/senders, manage the stoplist, and
|
|
16
|
+
register delivery callbacks — all returning typed, immutable result objects and
|
|
17
|
+
raising typed errors.
|
|
18
|
+
|
|
19
|
+
## Why smsru_ruby?
|
|
20
|
+
|
|
21
|
+
- **Zero runtime dependencies** — only Ruby's standard library (`net/http`, `json`, `openssl`).
|
|
22
|
+
- **Fully typed** — immutable `Data` result objects, not raw hashes, plus a typed error hierarchy: `rescue SmsRu::Error` catches everything.
|
|
23
|
+
- **RBS signatures shipped** (`sig/`) and Steep-checked — type-check your integration out of the box.
|
|
24
|
+
- **First-class webhooks** — parse signed delivery and call-authorization callbacks into typed events; the signature is verified in **constant time** (timing-attack safe).
|
|
25
|
+
- **Secret-safe by default** — TLS verified; the optional logger never logs your `api_id`, phone numbers, or message text. Configurable timeout and transport retries.
|
|
26
|
+
- **Outcome vs. delivery state** — two distinct ideas, each with its own predicates (`ok?` vs. `delivered?`/`pending?`/`failed?`), never conflated.
|
|
27
|
+
- **100% test & documentation coverage, enforced in CI** across Ruby 3.2–4.0.
|
|
28
|
+
|
|
29
|
+
## What's covered
|
|
30
|
+
|
|
31
|
+
The full SMS.ru API, mapped to an idiomatic Ruby surface:
|
|
32
|
+
|
|
33
|
+
| Capability | Method |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Send — single, bulk, or per-number text | `client.deliver` |
|
|
36
|
+
| Price a message before sending | `client.cost` |
|
|
37
|
+
| Delivery status, with state predicates | `client.status` |
|
|
38
|
+
| Verify by flash call (outbound) | `client.call` |
|
|
39
|
+
| Verify by callcheck (inbound) | `client.callcheck` |
|
|
40
|
+
| Balance, limits, free limit, senders | `client.my` |
|
|
41
|
+
| Validate credentials | `client.auth.ok?` |
|
|
42
|
+
| Stoplist — add, remove, list | `client.stoplist` |
|
|
43
|
+
| Webhook URLs — add, remove, list | `client.callbacks` |
|
|
44
|
+
| Parse & verify incoming webhooks | `SmsRu::Webhook` |
|
|
45
|
+
|
|
46
|
+
## Table of contents
|
|
47
|
+
|
|
48
|
+
- [Why smsru_ruby?](#why-smsru_ruby)
|
|
49
|
+
- [What's covered](#whats-covered)
|
|
50
|
+
- [Supported Ruby versions](#supported-ruby-versions)
|
|
51
|
+
- [Installation](#installation)
|
|
52
|
+
- [Quick start](#quick-start)
|
|
53
|
+
- [Configuration](#configuration)
|
|
54
|
+
- [Sending messages](#sending-messages)
|
|
55
|
+
- [Cost and status](#cost-and-status)
|
|
56
|
+
- [Verify by phone call](#verify-by-phone-call)
|
|
57
|
+
- [Account information](#account-information)
|
|
58
|
+
- [Stoplist](#stoplist)
|
|
59
|
+
- [Callbacks (webhooks)](#callbacks-webhooks)
|
|
60
|
+
- [Error handling](#error-handling)
|
|
61
|
+
- [Development](#development)
|
|
62
|
+
- [Recording test cassettes](#recording-test-cassettes)
|
|
63
|
+
- [License](#license)
|
|
64
|
+
|
|
65
|
+
## Supported Ruby versions
|
|
66
|
+
|
|
67
|
+
Ruby **3.2+** (the result objects use [`Data`](https://docs.ruby-lang.org/en/3.2/Data.html)).
|
|
68
|
+
CI runs against `ruby-head`, `4.0`, `3.4`, `3.3`, and `3.2`.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Gemfile
|
|
74
|
+
gem "smsru_ruby"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
bundle install
|
|
79
|
+
# or
|
|
80
|
+
gem install smsru_ruby
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
require "smsru_ruby"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Quick start
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
client = SmsRu.new("YOUR_API_ID")
|
|
91
|
+
|
|
92
|
+
result = client.deliver("79991234567", "Hello from Ruby!")
|
|
93
|
+
result.messages.first.sms_id # => "000000-10000000"
|
|
94
|
+
client.my.balance # => 4762.58
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Get your `api_id` in the SMS.ru dashboard under
|
|
98
|
+
[Settings → API](https://sms.ru/?panel=api).
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
SmsRu.new(
|
|
104
|
+
"YOUR_API_ID",
|
|
105
|
+
timeout: 30, # open/read timeout in seconds (default: 30)
|
|
106
|
+
test: false, # when true, every `deliver` defaults to test mode (no charge)
|
|
107
|
+
retries: 5, # retries on transport failure; 0 disables (default: 5, matching the PHP lib)
|
|
108
|
+
from: "MyCompany", # default sender name for `deliver` (override per call)
|
|
109
|
+
logger: Logger.new($stdout) # optional; logs the request path + transport failures
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Retries apply only to transport-level problems (timeouts, refused connections).
|
|
114
|
+
API errors are never retried — they are raised immediately.
|
|
115
|
+
|
|
116
|
+
`from` is a per-client default so you don't repeat your sender name on every call;
|
|
117
|
+
a per-call `from:` always wins. The `logger` logs only the request path and
|
|
118
|
+
transport failures — never your `api_id`, phone numbers, or message text.
|
|
119
|
+
|
|
120
|
+
## Sending messages
|
|
121
|
+
|
|
122
|
+
`#deliver` accepts the recipient(s) in three shapes:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# 1. One number
|
|
126
|
+
client.deliver("79991234567", "Hi there")
|
|
127
|
+
|
|
128
|
+
# 2. Same text to many numbers (Array)
|
|
129
|
+
client.deliver(["79991234567", "79991234568"], "Hi everyone")
|
|
130
|
+
|
|
131
|
+
# 3. A different text per number (Hash — do not pass a separate text).
|
|
132
|
+
# Use braces so Ruby treats it as a positional Hash, not keyword arguments.
|
|
133
|
+
client.deliver({
|
|
134
|
+
"79991234567" => "Hi Alice",
|
|
135
|
+
"79991234568" => "Hi Bob"
|
|
136
|
+
})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Optional keyword arguments (all optional):
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
client.deliver(
|
|
143
|
+
"79991234567", "Hi",
|
|
144
|
+
from: "MyCompany", # approved sender name
|
|
145
|
+
time: Time.now.to_i + 3600, # scheduled send (UNIX time, up to 2 months ahead)
|
|
146
|
+
ttl: 60, # message lifetime in minutes (1–1440)
|
|
147
|
+
daytime: true, # defer night-time sends to the recipient's daytime
|
|
148
|
+
translit: true, # transliterate Cyrillic to Latin
|
|
149
|
+
test: true, # test mode for this call (overrides the client default)
|
|
150
|
+
ip: "192.0.2.1", # end-user IP (for auth-code anti-fraud)
|
|
151
|
+
partner_id: 12345 # partner program id
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The result is a `SmsRu::SendResult`. Individual recipients can fail even when the
|
|
156
|
+
overall request succeeds, so inspect each message:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
result = client.deliver(["79991234567", "74993221627"], "Hi")
|
|
160
|
+
result.balance # => 4122.56
|
|
161
|
+
result.messages.each do |sms|
|
|
162
|
+
if sms.ok?
|
|
163
|
+
puts "#{sms.phone}: sent as #{sms.sms_id}"
|
|
164
|
+
else
|
|
165
|
+
puts "#{sms.phone}: rejected (#{sms.error_code}) #{sms.error_text}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Or use the collection helpers:
|
|
170
|
+
result.ok? # => true only if every recipient was accepted
|
|
171
|
+
result.ok # => [SmsRu::Sms, ...] accepted recipients
|
|
172
|
+
result.failed # => [SmsRu::Sms, ...] rejected recipients
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Cost and status
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# Price a message before sending (text is optional; omit it for the price of 1 SMS)
|
|
179
|
+
cost = client.cost("79991234567", "How much?")
|
|
180
|
+
cost.total_cost # => 1.74
|
|
181
|
+
cost.total_sms # => 2
|
|
182
|
+
|
|
183
|
+
# Same collection helpers as a send result:
|
|
184
|
+
cost.ok? # => true only if every recipient was priced
|
|
185
|
+
cost.failed # => [SmsRu::CostItem, ...] recipients that errored
|
|
186
|
+
cost.failed.first.error_code # => 207
|
|
187
|
+
|
|
188
|
+
# Delivery status — one id or an Array of ids
|
|
189
|
+
status = client.status("000000-10000000")
|
|
190
|
+
status.status_code # => 103 (the delivery state code)
|
|
191
|
+
status.status_text # => "Сообщение доставлено"
|
|
192
|
+
|
|
193
|
+
# State predicates instead of memorizing codes:
|
|
194
|
+
status.delivered? # => true (code 103)
|
|
195
|
+
status.pending? # => false (codes 100–102, still in transit)
|
|
196
|
+
status.failed? # => false (codes 104–108, 150)
|
|
197
|
+
status.found? # => true (false only when the id is unknown, code -1)
|
|
198
|
+
|
|
199
|
+
statuses = client.status(["000000-10000000", "000000-10000001"]) # => [SmsRu::Status, ...]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Every code has a named constant under `SmsRu::Statuses` (e.g.
|
|
203
|
+
`SmsRu::Statuses::DELIVERED == 103`, `::EXPIRED`, `::READ`) for the cases the
|
|
204
|
+
predicates don't cover. The same predicates are available on
|
|
205
|
+
`SmsRu::Events::SmsStatus` from webhook payloads.
|
|
206
|
+
|
|
207
|
+
> **Outcome vs. delivery state — two ideas, two names.** `ok?` (with
|
|
208
|
+
> `error_code`/`error_text` on a rejected `Sms`/`CostItem`) answers *did the
|
|
209
|
+
> request succeed for this recipient*. `status_code` (with
|
|
210
|
+
> `delivered?`/`pending?`/`failed?`) answers *where the message is in delivery* —
|
|
211
|
+
> and only `Status` and webhook events carry it.
|
|
212
|
+
|
|
213
|
+
## Verify by phone call
|
|
214
|
+
|
|
215
|
+
Two ways to verify a user by phone call — no SMS required.
|
|
216
|
+
|
|
217
|
+
**Outbound (flash call).** SMS.ru calls the user; the last 4 digits of the
|
|
218
|
+
calling number are the code. You receive the expected `code` to compare against
|
|
219
|
+
what the user enters:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
call = client.call("79991234567")
|
|
223
|
+
call.code # => "1435" — the last 4 digits the user will see
|
|
224
|
+
call.call_id # => "000000-10000000"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Inbound (callcheck).** The user calls a number you show them; SMS.ru drops the
|
|
228
|
+
call (free for the caller) and marks the check confirmed:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
check = client.callcheck.add("79991234567")
|
|
232
|
+
check.call_phone_pretty # => "+7 (800) 500-8275" — show this to the user
|
|
233
|
+
|
|
234
|
+
# Poll until the user has called (or receive it via a callback/webhook):
|
|
235
|
+
client.callcheck.status(check.check_id).confirmed? # => true
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Account information
|
|
239
|
+
|
|
240
|
+
Account reads are grouped under `client.my`:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
client.my.balance # => 4762.58 (a Float)
|
|
244
|
+
|
|
245
|
+
limit = client.my.limit
|
|
246
|
+
limit.total_limit # => 100
|
|
247
|
+
limit.used_today # => 7
|
|
248
|
+
limit.available_today # => 93
|
|
249
|
+
|
|
250
|
+
free = client.my.free_limit
|
|
251
|
+
free.total_free # => 5
|
|
252
|
+
free.used_today # => 3
|
|
253
|
+
free.available_today # => 2
|
|
254
|
+
|
|
255
|
+
client.my.senders # => ["MyCompany", "AnotherName"]
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Check that the configured `api_id` is valid:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
client.auth.ok? # => true
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Stoplist
|
|
265
|
+
|
|
266
|
+
Numbers on the stoplist never receive messages and are never charged.
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
client.stoplist.add("79991234567", note: "spam complaint") # => true
|
|
270
|
+
client.stoplist.list # => [#<data SmsRu::StoplistEntry phone="79991234567", note="spam complaint">]
|
|
271
|
+
client.stoplist.remove("79991234567") # => true
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Callbacks (webhooks)
|
|
275
|
+
|
|
276
|
+
Register URLs that SMS.ru will POST delivery and call-authorization statuses to.
|
|
277
|
+
Each method returns the full list of registered URLs:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
client.callbacks.add("https://example.com/sms/callback") # => ["https://example.com/sms/callback"]
|
|
281
|
+
client.callbacks.list # => [...]
|
|
282
|
+
client.callbacks.remove("https://example.com/sms/callback") # => [...]
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
In your webhook handler, verify the signature, parse the payload, and
|
|
286
|
+
acknowledge it by replying with the string `"100"`:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# Reject forged callbacks: SMS.ru signs every payload with your api_id.
|
|
290
|
+
# The check is constant-time (timing-attack safe).
|
|
291
|
+
unless SmsRu::Webhook.valid?(params["data"], params["hash"], "YOUR_API_ID")
|
|
292
|
+
return head(:forbidden)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# SMS.ru sends up to 100 records as POST fields data[0]..data[N]
|
|
296
|
+
# (a Hash in Rack/Rails, an Array in PHP). #parse handles either shape and
|
|
297
|
+
# returns a typed event per record.
|
|
298
|
+
SmsRu::Webhook.parse(params["data"]).each do |event|
|
|
299
|
+
case event
|
|
300
|
+
when SmsRu::Events::SmsStatus # delivery report
|
|
301
|
+
# event.id, event.status_code, event.created_at; event.delivered? => 103
|
|
302
|
+
update_delivery_status(event.id, event.status_code)
|
|
303
|
+
when SmsRu::Events::CallcheckStatus # call-authorization result
|
|
304
|
+
confirm_authorization(event.id) if event.confirmed? # or event.expired?
|
|
305
|
+
# SmsRu::Events::Test (heartbeat) and ::Unknown (future types) fall through
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Respond with exactly "100", or SMS.ru retries every 60s for up to 5 days.
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Error handling
|
|
313
|
+
|
|
314
|
+
Every error inherits from `SmsRu::Error`:
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
SmsRu::Error # base class
|
|
318
|
+
├─ SmsRu::ConnectionError # network/timeout/invalid response (after retries)
|
|
319
|
+
└─ SmsRu::ResponseError # API returned a non-OK status; has #code and #text
|
|
320
|
+
├─ SmsRu::AuthError # invalid api_id/token/account (codes 200, 300, 301, 302)
|
|
321
|
+
└─ SmsRu::InsufficientFundsError # not enough money (code 201)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
begin
|
|
326
|
+
client.deliver("79991234567", "Hi")
|
|
327
|
+
rescue SmsRu::AuthError => e
|
|
328
|
+
warn "Check your api_id: #{e.text}"
|
|
329
|
+
rescue SmsRu::InsufficientFundsError
|
|
330
|
+
warn "Top up your balance"
|
|
331
|
+
rescue SmsRu::ResponseError => e
|
|
332
|
+
warn "SMS.ru error #{e.code}: #{e.text}"
|
|
333
|
+
rescue SmsRu::ConnectionError => e
|
|
334
|
+
warn "Could not reach SMS.ru: #{e.message}"
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Note that per-recipient failures in a bulk `deliver` are **not** raised — they are
|
|
339
|
+
reported on each `SmsRu::Sms` in `result.messages` (see above).
|
|
340
|
+
|
|
341
|
+
## Development
|
|
342
|
+
|
|
343
|
+
```sh
|
|
344
|
+
bin/setup # install dependencies
|
|
345
|
+
bundle exec rake # run RuboCop, validate RBS signatures, and the test suite
|
|
346
|
+
bundle exec rake steep # type-check lib/ against sig/ (Steep, strict diagnostics)
|
|
347
|
+
bundle exec rake steep:stats # report type coverage (typed % per file)
|
|
348
|
+
bundle exec rake rbs:test # run the suite verifying real values against the signatures
|
|
349
|
+
bin/console # an IRB session with the gem loaded
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The signatures are held to their own standard: Steep runs under its **strict**
|
|
353
|
+
diagnostics (no implicit `untyped`, no unannotated collections) at **100% type
|
|
354
|
+
coverage**, gated in CI. Loosely-typed JSON from SMS.ru (which returns, say,
|
|
355
|
+
`total_limit` as the string `"10"`) is normalized into the declared types at the
|
|
356
|
+
parse boundary, and `rbs:test` checks that the values flowing through the suite
|
|
357
|
+
actually match `sig/` at runtime — so the types can't drift from the code.
|
|
358
|
+
|
|
359
|
+
## Recording test cassettes
|
|
360
|
+
|
|
361
|
+
End-to-end tests replay real SMS.ru responses recorded with [VCR](https://github.com/vcr/vcr).
|
|
362
|
+
The cassettes are not committed with secrets — your `api_id` is filtered out. To
|
|
363
|
+
record them once against your own account (message sends use `test=1`, so they are
|
|
364
|
+
free):
|
|
365
|
+
|
|
366
|
+
```sh
|
|
367
|
+
SMSRU_API_ID=your_real_api_id bundle exec rake vcr:record
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
This writes `test/cassettes/*.yml`. Commit them, then `COVERAGE=true bundle exec rake`
|
|
371
|
+
runs fully offline at 100% coverage. Before cassettes are recorded, the end-to-end
|
|
372
|
+
tests are skipped (the unit and transport tests still run).
|
|
373
|
+
|
|
374
|
+
## License
|
|
375
|
+
|
|
376
|
+
Released under the [MIT License](LICENSE.txt).
|
data/lib/sms_ru/auth.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SmsRu
|
|
4
|
+
# Authentication checks against the account. Reached via SmsRu#auth, e.g.
|
|
5
|
+
# `client.auth.ok?`.
|
|
6
|
+
class Auth
|
|
7
|
+
# @api private
|
|
8
|
+
# @param request [Method] the client's bound `request` method
|
|
9
|
+
def initialize(request)
|
|
10
|
+
@request = request
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Boolean] true when the configured api_id is valid
|
|
14
|
+
def ok?
|
|
15
|
+
@request.call("/auth/check")
|
|
16
|
+
true
|
|
17
|
+
rescue AuthError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SmsRu
|
|
4
|
+
# Authorizes a user by an incoming call from their own number: SMS.ru hands you
|
|
5
|
+
# a number, the user calls it, SMS.ru drops the call (free for the caller) and
|
|
6
|
+
# marks the check confirmed. Reached via SmsRu#callcheck.
|
|
7
|
+
#
|
|
8
|
+
# check = client.callcheck.add("79991234567")
|
|
9
|
+
# # show check.call_phone_pretty to the user, then poll until confirmed:
|
|
10
|
+
# client.callcheck.status(check.check_id).confirmed?
|
|
11
|
+
class CallCheck
|
|
12
|
+
# @api private
|
|
13
|
+
# @param request [Method] the client's bound `request` method
|
|
14
|
+
def initialize(request)
|
|
15
|
+
@request = request
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Starts a check and returns the number the user must call to authorize.
|
|
19
|
+
#
|
|
20
|
+
# @param phone [String, Integer] the user's phone number to authorize
|
|
21
|
+
# @return [SmsRu::CallCheckResult]
|
|
22
|
+
# @raise [SmsRu::ResponseError] if SMS.ru rejects the request
|
|
23
|
+
def add(phone) = CallCheckResult.build(@request.call("/callcheck/add", phone: phone.to_s))
|
|
24
|
+
|
|
25
|
+
# Polls whether the user has placed the authorizing call yet.
|
|
26
|
+
#
|
|
27
|
+
# @param check_id [String, Integer] the id returned by #add
|
|
28
|
+
# @return [SmsRu::CallCheckStatus]
|
|
29
|
+
# @raise [SmsRu::ResponseError] if SMS.ru rejects the request
|
|
30
|
+
def status(check_id) = CallCheckStatus.build(@request.call("/callcheck/status", check_id: check_id.to_s))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SmsRu
|
|
4
|
+
# Manages callback (webhook) URLs that SMS.ru notifies with delivery statuses.
|
|
5
|
+
# Reached via SmsRu#callbacks, e.g. `client.callbacks.add("https://...")`.
|
|
6
|
+
# Each method returns the full Array of registered URLs after the change.
|
|
7
|
+
class Callbacks
|
|
8
|
+
# @api private
|
|
9
|
+
# @param request [Method] the client's bound `request` method
|
|
10
|
+
def initialize(request)
|
|
11
|
+
@request = request
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Registers a callback URL.
|
|
15
|
+
#
|
|
16
|
+
# @param url [String] the webhook URL to register
|
|
17
|
+
# @return [Array<String>] all registered URLs after the change
|
|
18
|
+
# @raise [SmsRu::ResponseError] if SMS.ru rejects the request
|
|
19
|
+
def add(url) = urls(@request.call("/callback/add", url: url))
|
|
20
|
+
|
|
21
|
+
# Removes a callback URL.
|
|
22
|
+
#
|
|
23
|
+
# @param url [String] the webhook URL to remove
|
|
24
|
+
# @return [Array<String>] all registered URLs after the change
|
|
25
|
+
# @raise [SmsRu::ResponseError] if SMS.ru rejects the request
|
|
26
|
+
def remove(url) = urls(@request.call("/callback/del", url: url))
|
|
27
|
+
|
|
28
|
+
# Lists the registered callback URLs.
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<String>] all registered URLs
|
|
31
|
+
# @raise [SmsRu::ResponseError] if SMS.ru rejects the request
|
|
32
|
+
def list = urls(@request.call("/callback/get"))
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def urls(data) = data["callback"] || []
|
|
37
|
+
end
|
|
38
|
+
end
|