ararahq 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/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +124 -0
- data/arara.gemspec +23 -0
- data/lib/arara/client.rb +38 -0
- data/lib/arara/errors.rb +87 -0
- data/lib/arara/http_client.rb +186 -0
- data/lib/arara/page.rb +57 -0
- data/lib/arara/resources/auth.rb +12 -0
- data/lib/arara/resources/base_resource.rb +18 -0
- data/lib/arara/resources/campaigns.rb +38 -0
- data/lib/arara/resources/contacts.rb +52 -0
- data/lib/arara/resources/conversations.rb +39 -0
- data/lib/arara/resources/messages.rb +69 -0
- data/lib/arara/resources/numbers.rb +57 -0
- data/lib/arara/resources/opt_outs.rb +31 -0
- data/lib/arara/resources/smart_links.rb +42 -0
- data/lib/arara/resources/templates.rb +61 -0
- data/lib/arara/resources/wallet.rb +31 -0
- data/lib/arara/version.rb +3 -0
- data/lib/arara.rb +7 -0
- metadata +69 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 76d8cd62472512d57be879f384202d4c0075c6e281328e265ad8103391232c1a
|
|
4
|
+
data.tar.gz: efc27423be8a8a20e965fb81e74d463b217631cd8bdfb8f7f07529807fabdae5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5150377594577e64ce679080a5bde8ed93b993a6ec48031d502391f82d5af9f894392c62b19e568121aa3f37b6ee59bc48b32e16762ef89e1d0ea195c561285b
|
|
7
|
+
data.tar.gz: 7b21d10c51ac110ee75f197cd706a97c3c97d2c1c9e7131191d7e684af38e67fd8853e8cc180a8dfa54032559c3a2c7cb70d728fe10c203b315a57b83c6ac65e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 - 2026-09-24
|
|
4
|
+
|
|
5
|
+
Primeira versão publicada no RubyGems. Alinha o SDK ao contrato da API por chave.
|
|
6
|
+
|
|
7
|
+
### Breaking
|
|
8
|
+
- `messages.send` foi removido (sobrescrevia `Object#send`). Use `messages.send_message` ou `messages.deliver`.
|
|
9
|
+
- `templates.get`, `get_status` e `delete` recebem o `id` (UUID) do template, não o nome. Para buscar por nome, `templates.find_by_name` (filtro local sobre `list`).
|
|
10
|
+
- `templates.list`, `smart_links.list`, `campaigns.list` e `wallet.transactions` devolvem `Arara::Page` (`data` + `pagination`).
|
|
11
|
+
- Removidos `users`, `organizations` (webhook) e `api_keys`: não são alcançáveis por chave. Use `auth.me` (`GET /auth/me`).
|
|
12
|
+
- 403 sem `code` agora levanta `Arara::AuthenticationError`.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `Idempotency-Key` automático (UUID v4) em `messages.send_message`, `messages.send_batch` e `campaigns.create`, reaproveitado em todas as retentativas.
|
|
16
|
+
- `sender`, `type`, `interactive`, `charge`, `location`, `reaction`, `reply_to`, `smart_link_param`, `smart_link_url` e `mode` no envio.
|
|
17
|
+
- `messages.get`, `messages.send_batch` (até 1000), `messages.list_by_batch`.
|
|
18
|
+
- `templates.analytics`, `templates.template_analytics`, filtros e paginação em `templates.list`; paginação em `smart_links.list`.
|
|
19
|
+
- `opt_outs` (`list`, `create`, `get`, `delete`).
|
|
20
|
+
- Erros `PlanFeatureLockedError` (`feature`, `current_plan`, `upgrade_to`), `PaymentRequiredError` (402) e `UnprocessableEntityError` (422).
|
|
21
|
+
- LICENSE MIT e suíte de testes (minitest).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- `Retry-After` em formato de data não derruba mais a chamada (`require "time"`).
|
|
25
|
+
- POST/PATCH sem `Idempotency-Key` não é mais repetido em 5xx, 429 ou erro de rede.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arara HQ
|
|
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,124 @@
|
|
|
1
|
+
# AraraHQ Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [AraraHQ](https://ararahq.com) WhatsApp API. Zero runtime dependencies: pure Ruby stdlib (`net/http`, `json`, `uri`, `time`, `securerandom`). Ruby >= 3.0.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "ararahq"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gem install ararahq
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "arara"
|
|
19
|
+
|
|
20
|
+
client = Arara::Client.new(api_key: "ara_live_...")
|
|
21
|
+
|
|
22
|
+
# Send a template message. `deliver` is an alias of `send_message`.
|
|
23
|
+
result = client.messages.send_message(
|
|
24
|
+
receiver: "5511999998888", # also accepts "+55..." and "whatsapp:+55..."
|
|
25
|
+
sender: "+5511888887777", # optional: which of your numbers sends it
|
|
26
|
+
template_name: "boas_vindas",
|
|
27
|
+
template_variables: ["Micael"],
|
|
28
|
+
idempotency_key: "order-1234" # optional: generated when omitted
|
|
29
|
+
)
|
|
30
|
+
puts result["id"]
|
|
31
|
+
|
|
32
|
+
client.messages.get(result["id"])
|
|
33
|
+
|
|
34
|
+
# Up to 1000 receivers of the same template
|
|
35
|
+
client.messages.send_batch(
|
|
36
|
+
template_name: "boas_vindas",
|
|
37
|
+
messages: [{ "receiver" => "5511999998888", "variables" => ["Ana"] }]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Paginated lists return Arara::Page (Enumerable over `data`)
|
|
41
|
+
page = client.templates.list(status: "APPROVED", page: 0, size: 50)
|
|
42
|
+
page.each { |template| puts template["id"] }
|
|
43
|
+
page.pagination.total_pages
|
|
44
|
+
page.next_page?
|
|
45
|
+
|
|
46
|
+
# Templates are addressed by id (UUID)
|
|
47
|
+
client.templates.get_status(page.data.first["id"])
|
|
48
|
+
client.templates.find_by_name("boas_vindas") # local filter over list
|
|
49
|
+
|
|
50
|
+
# Campaigns (idempotency key is auto-generated when omitted)
|
|
51
|
+
client.campaigns.create(
|
|
52
|
+
{
|
|
53
|
+
"name" => "Reativação Julho",
|
|
54
|
+
"templateName" => "reativacao",
|
|
55
|
+
"contacts" => [{ "to" => "5511999998888", "variables" => ["Micael"] }]
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Responses are parsed from JSON into plain Ruby `Hash` objects with string keys, exactly as the API returns them.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Arara::Client.new(
|
|
66
|
+
api_key: "ara_live_...",
|
|
67
|
+
base_url: "https://api.ararahq.com", # default
|
|
68
|
+
timeout: 10, # seconds, default
|
|
69
|
+
max_retries: 3 # default
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`GET`, `PUT` and `DELETE`, and any request carrying an `Idempotency-Key`, are retried on `429`, `5xx` and network errors with exponential backoff, honoring `Retry-After`. A `POST`/`PATCH` without `Idempotency-Key` is never retried. `messages.send_message`, `messages.send_batch` and `campaigns.create` always send one (a UUID v4 when you do not pass yours), reused across retries, so a retry never duplicates a send.
|
|
74
|
+
|
|
75
|
+
## Resources
|
|
76
|
+
|
|
77
|
+
`auth`, `messages`, `templates`, `contacts`, `conversations`, `wallet`, `numbers`, `smart_links`, `campaigns`, `opt_outs`.
|
|
78
|
+
|
|
79
|
+
## API key permissions
|
|
80
|
+
|
|
81
|
+
`READ` keys can `GET` messages, campaigns, templates and numbers. Sending needs `MESSAGES_SEND` (messages) or `CAMPAIGNS_SEND` (campaigns); creating templates needs `TEMPLATES_WRITE`; `contacts` writes need `CONTACTS_WRITE`.
|
|
82
|
+
|
|
83
|
+
These require an `ADMIN` key: `auth.me`, `contacts` (reads), `conversations`, `wallet` and `opt_outs`. Permissions are enforced by the API.
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
Every failed request raises an `Arara::Error` (or a subclass) with `status_code`, `code`, `message`, `details` and `retry_after`.
|
|
88
|
+
|
|
89
|
+
| Status | Class |
|
|
90
|
+
|---|---|
|
|
91
|
+
| 400 | `Arara::BadRequestError` |
|
|
92
|
+
| 401, 403 without `code` (invalid key or missing permission) | `Arara::AuthenticationError` |
|
|
93
|
+
| 402 | `Arara::PaymentRequiredError` |
|
|
94
|
+
| 403 `PLAN_FEATURE_LOCKED` | `Arara::PlanFeatureLockedError` (`feature`, `current_plan`, `upgrade_to`) |
|
|
95
|
+
| 403 with another `code` | `Arara::PermissionError` |
|
|
96
|
+
| 404 | `Arara::NotFoundError` |
|
|
97
|
+
| 409 | `Arara::ConflictError` |
|
|
98
|
+
| 422 | `Arara::UnprocessableEntityError` (e.g. `INVALID_RECIPIENT`, `TEMPLATE_PAUSED`) |
|
|
99
|
+
| 429 | `Arara::RateLimitError` |
|
|
100
|
+
| 5xx | `Arara::ServerError` |
|
|
101
|
+
| network | `Arara::NetworkError` |
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
begin
|
|
105
|
+
client.messages.send_message(receiver: "5511999998888", template_name: "x")
|
|
106
|
+
rescue Arara::PlanFeatureLockedError => e
|
|
107
|
+
puts "upgrade to #{e.upgrade_to}"
|
|
108
|
+
rescue Arara::RateLimitError => e
|
|
109
|
+
puts "retry after #{e.retry_after}s"
|
|
110
|
+
rescue Arara::Error => e
|
|
111
|
+
puts "#{e.code}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bundle install
|
|
119
|
+
bundle exec rake test
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT. See [LICENSE](LICENSE).
|
data/arara.gemspec
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative "lib/arara/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "ararahq"
|
|
5
|
+
spec.version = Arara::VERSION
|
|
6
|
+
spec.authors = ["AraraHQ"]
|
|
7
|
+
spec.email = ["dev@ararahq.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "Official Ruby SDK for the AraraHQ WhatsApp API."
|
|
10
|
+
spec.description = "Ruby client for the AraraHQ API: messages, templates, contacts, " \
|
|
11
|
+
"conversations, campaigns, wallet, numbers, smart links and opt-outs. Zero runtime dependencies."
|
|
12
|
+
spec.homepage = "https://ararahq.com"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.0"
|
|
15
|
+
|
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["source_code_uri"] = "https://github.com/ararahq/arara-ruby-sdk"
|
|
18
|
+
spec.metadata["changelog_uri"] = "https://github.com/ararahq/arara-ruby-sdk/blob/main/CHANGELOG.md"
|
|
19
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
20
|
+
|
|
21
|
+
spec.files = Dir["lib/**/*.rb"] + ["README.md", "CHANGELOG.md", "LICENSE", "arara.gemspec"]
|
|
22
|
+
spec.require_paths = ["lib"]
|
|
23
|
+
end
|
data/lib/arara/client.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative "http_client"
|
|
2
|
+
require_relative "resources/auth"
|
|
3
|
+
require_relative "resources/messages"
|
|
4
|
+
require_relative "resources/templates"
|
|
5
|
+
require_relative "resources/contacts"
|
|
6
|
+
require_relative "resources/conversations"
|
|
7
|
+
require_relative "resources/wallet"
|
|
8
|
+
require_relative "resources/numbers"
|
|
9
|
+
require_relative "resources/smart_links"
|
|
10
|
+
require_relative "resources/campaigns"
|
|
11
|
+
require_relative "resources/opt_outs"
|
|
12
|
+
|
|
13
|
+
module Arara
|
|
14
|
+
class Client
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.ararahq.com".freeze
|
|
16
|
+
DEFAULT_TIMEOUT = 10
|
|
17
|
+
|
|
18
|
+
attr_reader :auth, :messages, :templates, :contacts, :conversations, :wallet,
|
|
19
|
+
:numbers, :smart_links, :campaigns, :opt_outs
|
|
20
|
+
|
|
21
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
22
|
+
max_retries: HttpClient::DEFAULT_MAX_RETRIES)
|
|
23
|
+
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
|
|
24
|
+
|
|
25
|
+
http = HttpClient.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries)
|
|
26
|
+
@auth = Resources::Auth.new(http)
|
|
27
|
+
@messages = Resources::Messages.new(http)
|
|
28
|
+
@templates = Resources::Templates.new(http)
|
|
29
|
+
@contacts = Resources::Contacts.new(http)
|
|
30
|
+
@conversations = Resources::Conversations.new(http)
|
|
31
|
+
@wallet = Resources::Wallet.new(http)
|
|
32
|
+
@numbers = Resources::Numbers.new(http)
|
|
33
|
+
@smart_links = Resources::SmartLinks.new(http)
|
|
34
|
+
@campaigns = Resources::Campaigns.new(http)
|
|
35
|
+
@opt_outs = Resources::OptOuts.new(http)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/arara/errors.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Arara
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :status_code, :code, :details, :retry_after
|
|
4
|
+
|
|
5
|
+
def initialize(message, code: nil, status_code: nil, details: nil, retry_after: nil)
|
|
6
|
+
super(message)
|
|
7
|
+
@code = code
|
|
8
|
+
@status_code = status_code
|
|
9
|
+
@details = details
|
|
10
|
+
@retry_after = retry_after
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class BadRequestError < Error; end
|
|
15
|
+
|
|
16
|
+
class AuthenticationError < Error; end
|
|
17
|
+
|
|
18
|
+
class PaymentRequiredError < Error; end
|
|
19
|
+
|
|
20
|
+
class PermissionError < Error; end
|
|
21
|
+
|
|
22
|
+
class PlanFeatureLockedError < PermissionError
|
|
23
|
+
def feature
|
|
24
|
+
detail("feature")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def current_plan
|
|
28
|
+
detail("currentPlan")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def upgrade_to
|
|
32
|
+
detail("upgradeTo")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def detail(key)
|
|
38
|
+
details.is_a?(Hash) ? details[key] : nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class NotFoundError < Error; end
|
|
43
|
+
|
|
44
|
+
class ConflictError < Error; end
|
|
45
|
+
|
|
46
|
+
class UnprocessableEntityError < Error; end
|
|
47
|
+
|
|
48
|
+
class RateLimitError < Error; end
|
|
49
|
+
|
|
50
|
+
class ServerError < Error; end
|
|
51
|
+
|
|
52
|
+
class NetworkError < Error; end
|
|
53
|
+
|
|
54
|
+
PLAN_FEATURE_LOCKED = "PLAN_FEATURE_LOCKED".freeze
|
|
55
|
+
SERVER_ERROR_THRESHOLD = 500
|
|
56
|
+
|
|
57
|
+
STATUS_ERRORS = {
|
|
58
|
+
400 => BadRequestError,
|
|
59
|
+
401 => AuthenticationError,
|
|
60
|
+
402 => PaymentRequiredError,
|
|
61
|
+
404 => NotFoundError,
|
|
62
|
+
409 => ConflictError,
|
|
63
|
+
422 => UnprocessableEntityError,
|
|
64
|
+
429 => RateLimitError
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def error_for_status(status_code, message, code: nil, details: nil, retry_after: nil)
|
|
70
|
+
klass = error_class_for(status_code, code)
|
|
71
|
+
klass.new(message, code: code, status_code: status_code, details: details, retry_after: retry_after)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def error_class_for(status_code, code)
|
|
75
|
+
return forbidden_error_class(code) if status_code == 403
|
|
76
|
+
return STATUS_ERRORS[status_code] if STATUS_ERRORS.key?(status_code)
|
|
77
|
+
|
|
78
|
+
status_code && status_code >= SERVER_ERROR_THRESHOLD ? ServerError : Error
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def forbidden_error_class(code)
|
|
82
|
+
return AuthenticationError if code.nil? || code.to_s.empty?
|
|
83
|
+
return PlanFeatureLockedError if code == PLAN_FEATURE_LOCKED
|
|
84
|
+
|
|
85
|
+
PermissionError
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Arara
|
|
8
|
+
class HttpClient
|
|
9
|
+
DEFAULT_MAX_RETRIES = 3
|
|
10
|
+
BASE_RETRY_DELAY = 0.5
|
|
11
|
+
MAX_RETRY_DELAY = 30.0
|
|
12
|
+
RATE_LIMIT_STATUS = 429
|
|
13
|
+
SERVER_ERROR_THRESHOLD = 500
|
|
14
|
+
IDEMPOTENT_METHODS = %i[get put delete].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(api_key:, base_url:, timeout: 10, max_retries: DEFAULT_MAX_RETRIES)
|
|
17
|
+
@api_key = api_key
|
|
18
|
+
@base_uri = URI.parse(base_url.to_s.sub(%r{/+\z}, ""))
|
|
19
|
+
@timeout = timeout
|
|
20
|
+
@max_retries = max_retries
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def get(path, params: nil)
|
|
24
|
+
request(:get, path, params: params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def post(path, body: nil, params: nil, idempotency_key: nil)
|
|
28
|
+
request(:post, path, body: body, params: params, idempotency_key: idempotency_key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def patch(path, body: nil, params: nil)
|
|
32
|
+
request(:patch, path, body: body, params: params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def put(path, body: nil, params: nil)
|
|
36
|
+
request(:put, path, body: body, params: params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def delete(path, params: nil)
|
|
40
|
+
request(:delete, path, params: params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def request(method, path, body: nil, params: nil, idempotency_key: nil)
|
|
46
|
+
idempotency_key = normalize_idempotency_key(idempotency_key)
|
|
47
|
+
attempt = 0
|
|
48
|
+
loop do
|
|
49
|
+
response = perform(method, path, body, params, idempotency_key)
|
|
50
|
+
status = response.code.to_i
|
|
51
|
+
return handle_success(response) if status < 400
|
|
52
|
+
|
|
53
|
+
if retry_allowed?(method, idempotency_key) && retryable?(status) && attempt < @max_retries
|
|
54
|
+
sleep(retry_delay(attempt, response))
|
|
55
|
+
attempt += 1
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
raise error_from_response(status, response)
|
|
59
|
+
rescue Timeout::Error, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
60
|
+
raise NetworkError.new(e.message) if attempt >= @max_retries || !retry_allowed?(method, idempotency_key)
|
|
61
|
+
|
|
62
|
+
sleep(retry_delay(attempt, nil))
|
|
63
|
+
attempt += 1
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def perform(method, path, body, params, idempotency_key)
|
|
68
|
+
uri = build_uri(path, params)
|
|
69
|
+
request = build_request(method, uri, body, idempotency_key)
|
|
70
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
71
|
+
http.use_ssl = uri.scheme == "https"
|
|
72
|
+
http.open_timeout = @timeout
|
|
73
|
+
http.read_timeout = @timeout
|
|
74
|
+
http.request(request)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_uri(path, params)
|
|
78
|
+
uri = @base_uri.dup
|
|
79
|
+
uri.path = "#{@base_uri.path}#{path}"
|
|
80
|
+
unless params.nil? || params.empty?
|
|
81
|
+
cleaned = params.reject { |_, value| value.nil? }
|
|
82
|
+
uri.query = URI.encode_www_form(cleaned) unless cleaned.empty?
|
|
83
|
+
end
|
|
84
|
+
uri
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_request(method, uri, body, idempotency_key)
|
|
88
|
+
klass = {
|
|
89
|
+
get: Net::HTTP::Get,
|
|
90
|
+
post: Net::HTTP::Post,
|
|
91
|
+
patch: Net::HTTP::Patch,
|
|
92
|
+
put: Net::HTTP::Put,
|
|
93
|
+
delete: Net::HTTP::Delete
|
|
94
|
+
}.fetch(method)
|
|
95
|
+
request = klass.new(uri)
|
|
96
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
97
|
+
request["Accept"] = "application/json"
|
|
98
|
+
request["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
99
|
+
unless body.nil?
|
|
100
|
+
request["Content-Type"] = "application/json"
|
|
101
|
+
request.body = JSON.generate(body)
|
|
102
|
+
end
|
|
103
|
+
request
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_success(response)
|
|
107
|
+
return nil if response.code.to_i == 204
|
|
108
|
+
|
|
109
|
+
raw = response.body
|
|
110
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
111
|
+
|
|
112
|
+
JSON.parse(raw)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
raise Error.new("Failed to parse Arara API response as JSON")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def normalize_idempotency_key(key)
|
|
118
|
+
normalized = key.to_s.strip
|
|
119
|
+
normalized.empty? ? nil : normalized
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def retry_allowed?(method, idempotency_key)
|
|
123
|
+
IDEMPOTENT_METHODS.include?(method) || !idempotency_key.nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def retryable?(status)
|
|
127
|
+
status == RATE_LIMIT_STATUS || status >= SERVER_ERROR_THRESHOLD
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def retry_delay(attempt, response)
|
|
131
|
+
after = response && parse_retry_after(response["Retry-After"])
|
|
132
|
+
return [after, MAX_RETRY_DELAY].min if after
|
|
133
|
+
|
|
134
|
+
[BASE_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY].min
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_retry_after(header)
|
|
138
|
+
return nil if header.nil? || header.strip.empty?
|
|
139
|
+
|
|
140
|
+
seconds = Float(header, exception: false)
|
|
141
|
+
return seconds if seconds && seconds >= 0
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
[(Time.httpdate(header) - Time.now).ceil, 0].max
|
|
145
|
+
rescue ArgumentError, TypeError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def error_from_response(status, response)
|
|
151
|
+
envelope = parse_error_envelope(response.body)
|
|
152
|
+
message = envelope[:message] || "Arara API request failed with status #{status}"
|
|
153
|
+
Arara.error_for_status(
|
|
154
|
+
status,
|
|
155
|
+
message,
|
|
156
|
+
code: envelope[:code],
|
|
157
|
+
details: envelope[:details],
|
|
158
|
+
retry_after: parse_retry_after(response["Retry-After"])
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def parse_error_envelope(raw)
|
|
163
|
+
return {} if raw.nil? || raw.strip.empty?
|
|
164
|
+
|
|
165
|
+
parsed = JSON.parse(raw)
|
|
166
|
+
return {} unless parsed.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
inner = parsed["error"]
|
|
169
|
+
return spring_error(parsed) if inner.is_a?(String)
|
|
170
|
+
return {} unless inner.is_a?(Hash)
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
code: inner["code"],
|
|
174
|
+
message: inner["message"],
|
|
175
|
+
details: inner["details"].is_a?(Hash) ? inner["details"] : nil
|
|
176
|
+
}
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def spring_error(parsed)
|
|
182
|
+
message = parsed["message"]
|
|
183
|
+
{ message: message.is_a?(String) && !message.strip.empty? ? message : nil }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/arara/page.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Arara
|
|
2
|
+
Pagination = Struct.new(:page, :size, :total_elements, :total_pages, keyword_init: true)
|
|
3
|
+
|
|
4
|
+
class Page
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :data, :pagination, :raw
|
|
8
|
+
|
|
9
|
+
def self.from_data(response)
|
|
10
|
+
body = require_list(response, "data")
|
|
11
|
+
meta = body["pagination"].is_a?(Hash) ? body["pagination"] : {}
|
|
12
|
+
new(
|
|
13
|
+
data: body["data"],
|
|
14
|
+
pagination: Pagination.new(
|
|
15
|
+
page: meta["page"], size: meta["size"],
|
|
16
|
+
total_elements: meta["totalElements"], total_pages: meta["totalPages"]
|
|
17
|
+
),
|
|
18
|
+
raw: response
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_content(response, page:, size:)
|
|
23
|
+
body = require_list(response, "content")
|
|
24
|
+
new(
|
|
25
|
+
data: body["content"],
|
|
26
|
+
pagination: Pagination.new(
|
|
27
|
+
page: page, size: size,
|
|
28
|
+
total_elements: body["totalElements"], total_pages: body["totalPages"]
|
|
29
|
+
),
|
|
30
|
+
raw: response
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.require_list(response, field)
|
|
35
|
+
return response if response.is_a?(Hash) && response[field].is_a?(Array)
|
|
36
|
+
|
|
37
|
+
raise Arara::Error.new("Unexpected paginated response from Arara API: missing \"#{field}\" list")
|
|
38
|
+
end
|
|
39
|
+
private_class_method :require_list
|
|
40
|
+
|
|
41
|
+
def initialize(data:, pagination:, raw: nil)
|
|
42
|
+
@data = data
|
|
43
|
+
@pagination = pagination
|
|
44
|
+
@raw = raw
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def each(&block)
|
|
48
|
+
@data.each(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def next_page?
|
|
52
|
+
return false if @pagination.page.nil? || @pagination.total_pages.nil?
|
|
53
|
+
|
|
54
|
+
@pagination.page + 1 < @pagination.total_pages
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Arara
|
|
4
|
+
module Resources
|
|
5
|
+
class BaseResource
|
|
6
|
+
def initialize(http)
|
|
7
|
+
@http = http
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def idempotency_key_or_generate(key)
|
|
13
|
+
normalized = key.to_s.strip
|
|
14
|
+
normalized.empty? ? SecureRandom.uuid : normalized
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require_relative "base_resource"
|
|
3
|
+
require_relative "../page"
|
|
4
|
+
|
|
5
|
+
module Arara
|
|
6
|
+
module Resources
|
|
7
|
+
class Campaigns < BaseResource
|
|
8
|
+
DEFAULT_PAGE_SIZE = 20
|
|
9
|
+
|
|
10
|
+
# Create a campaign. POST /v1/campaigns
|
|
11
|
+
def create(payload, idempotency_key: nil)
|
|
12
|
+
key = idempotency_key_or_generate(idempotency_key)
|
|
13
|
+
@http.post("/v1/campaigns", body: payload, idempotency_key: key)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# List campaigns. GET /v1/campaigns. Returns an Arara::Page.
|
|
17
|
+
def list(page: 0, size: DEFAULT_PAGE_SIZE, status: nil)
|
|
18
|
+
response = @http.get("/v1/campaigns", params: { "page" => page, "size" => size, "status" => status })
|
|
19
|
+
Arara::Page.from_content(response, page: page, size: size)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Estimate campaign cost. GET /v1/campaigns/estimate
|
|
23
|
+
def estimate(template_name:, count:)
|
|
24
|
+
@http.get("/v1/campaigns/estimate", params: { "templateName" => template_name, "count" => count })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Get a campaign by id. GET /v1/campaigns/{id}
|
|
28
|
+
def get(id)
|
|
29
|
+
@http.get("/v1/campaigns/#{id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Cancel a campaign. POST /v1/campaigns/{id}/cancel
|
|
33
|
+
def cancel(id)
|
|
34
|
+
@http.post("/v1/campaigns/#{id}/cancel")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
|
|
3
|
+
module Arara
|
|
4
|
+
module Resources
|
|
5
|
+
class Contacts < BaseResource
|
|
6
|
+
# List contacts. GET /v1/contacts
|
|
7
|
+
def list(page: 0, size: 20, q: nil, lifecycle: nil)
|
|
8
|
+
@http.get("/v1/contacts", params: { "page" => page, "size" => size, "q" => q, "lifecycle" => lifecycle })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Import a batch of contacts. POST /v1/contacts/batch
|
|
12
|
+
def import_batch(contacts)
|
|
13
|
+
@http.post("/v1/contacts/batch", body: contacts)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get contact lifecycle stats. GET /v1/contacts/stats
|
|
17
|
+
def stats
|
|
18
|
+
@http.get("/v1/contacts/stats")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# List reactivation candidates. GET /v1/contacts/reactivation
|
|
22
|
+
def reactivation_candidates(limit: 100)
|
|
23
|
+
@http.get("/v1/contacts/reactivation", params: { "limit" => limit })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# List distinct contact tags. GET /v1/contacts/tags
|
|
27
|
+
def list_tags
|
|
28
|
+
@http.get("/v1/contacts/tags")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get a contact by phone. GET /v1/contacts/{phone}
|
|
32
|
+
def get(phone)
|
|
33
|
+
@http.get("/v1/contacts/#{phone}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Update a contact by phone. PATCH /v1/contacts/{phone}
|
|
37
|
+
def update(phone, name: nil, email: nil, tags: nil)
|
|
38
|
+
payload = {
|
|
39
|
+
"name" => name,
|
|
40
|
+
"email" => email,
|
|
41
|
+
"tags" => tags
|
|
42
|
+
}.reject { |_, value| value.nil? }
|
|
43
|
+
@http.patch("/v1/contacts/#{phone}", body: payload)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List a contact's recent messages. GET /v1/contacts/{phone}/messages
|
|
47
|
+
def messages(phone, limit: 30)
|
|
48
|
+
@http.get("/v1/contacts/#{phone}/messages", params: { "limit" => limit })
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
|
|
3
|
+
module Arara
|
|
4
|
+
module Resources
|
|
5
|
+
class Conversations < BaseResource
|
|
6
|
+
# List conversations. GET /v1/conversations
|
|
7
|
+
def list(status: nil, lead_status: nil, page: 0, size: 20)
|
|
8
|
+
@http.get("/v1/conversations", params: {
|
|
9
|
+
"status" => status, "leadStatus" => lead_status, "page" => page, "size" => size
|
|
10
|
+
})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Get lead status stats. GET /v1/conversations/lead-stats
|
|
14
|
+
def lead_stats
|
|
15
|
+
@http.get("/v1/conversations/lead-stats")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# List messages in a conversation. GET /v1/conversations/{conversationId}/messages
|
|
19
|
+
def messages(conversation_id, page: 0, size: 50)
|
|
20
|
+
@http.get("/v1/conversations/#{conversation_id}/messages", params: { "page" => page, "size" => size })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Reply within an open conversation window. POST /v1/conversations/reply
|
|
24
|
+
def reply(conversation_id:, body:)
|
|
25
|
+
@http.post("/v1/conversations/reply", body: { "conversationId" => conversation_id, "body" => body })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Update a conversation status. PATCH /v1/conversations/{conversationId}/status
|
|
29
|
+
def update_status(conversation_id, status)
|
|
30
|
+
@http.patch("/v1/conversations/#{conversation_id}/status", body: { "status" => status })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check the 24h window status for phones. POST /v1/conversations/window-status
|
|
34
|
+
def window_status(phones)
|
|
35
|
+
@http.post("/v1/conversations/window-status", body: { "phones" => phones })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require_relative "base_resource"
|
|
3
|
+
|
|
4
|
+
module Arara
|
|
5
|
+
module Resources
|
|
6
|
+
class Messages < BaseResource
|
|
7
|
+
BASE_PATH = "/v1/messages".freeze
|
|
8
|
+
MAX_BATCH_SIZE = 1000
|
|
9
|
+
|
|
10
|
+
PAYLOAD_FIELDS = {
|
|
11
|
+
receiver: "receiver",
|
|
12
|
+
sender: "sender",
|
|
13
|
+
type: "type",
|
|
14
|
+
template_name: "templateName",
|
|
15
|
+
template_variables: "templateVariables",
|
|
16
|
+
body: "body",
|
|
17
|
+
interactive: "interactive",
|
|
18
|
+
charge: "charge",
|
|
19
|
+
location: "location",
|
|
20
|
+
reaction: "reaction",
|
|
21
|
+
reply_to: "replyTo",
|
|
22
|
+
smart_link_param: "smartLinkParam",
|
|
23
|
+
smart_link_url: "smartLinkUrl",
|
|
24
|
+
scheduled_at: "scheduled_at",
|
|
25
|
+
mode: "mode",
|
|
26
|
+
media_url: "media_url"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Send a WhatsApp message. POST /v1/messages
|
|
30
|
+
# An Idempotency-Key is generated when omitted and reused on every retry.
|
|
31
|
+
def send_message(receiver:, idempotency_key: nil, **fields)
|
|
32
|
+
unknown = fields.keys - PAYLOAD_FIELDS.keys
|
|
33
|
+
raise ArgumentError, "unknown message fields: #{unknown.join(', ')}" unless unknown.empty?
|
|
34
|
+
|
|
35
|
+
payload = build_payload(fields.merge(receiver: receiver))
|
|
36
|
+
@http.post(BASE_PATH, body: payload, idempotency_key: idempotency_key_or_generate(idempotency_key))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
alias deliver send_message
|
|
40
|
+
|
|
41
|
+
# Send the same template to up to 1000 receivers. POST /v1/messages/batch
|
|
42
|
+
def send_batch(template_name:, messages:, idempotency_key: nil)
|
|
43
|
+
raise ArgumentError, "messages must not be empty" if messages.nil? || messages.empty?
|
|
44
|
+
raise ArgumentError, "messages must have at most #{MAX_BATCH_SIZE} items" if messages.size > MAX_BATCH_SIZE
|
|
45
|
+
|
|
46
|
+
payload = { "templateName" => template_name, "messages" => messages }
|
|
47
|
+
@http.post("#{BASE_PATH}/batch", body: payload, idempotency_key: idempotency_key_or_generate(idempotency_key))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get a message by id. GET /v1/messages/{id}
|
|
51
|
+
def get(id)
|
|
52
|
+
@http.get("#{BASE_PATH}/#{id}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# List the messages of a batch. GET /v1/messages?batchId=
|
|
56
|
+
def list_by_batch(batch_id)
|
|
57
|
+
@http.get(BASE_PATH, params: { "batchId" => batch_id })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def build_payload(fields)
|
|
63
|
+
fields.each_with_object({}) do |(key, value), payload|
|
|
64
|
+
payload[PAYLOAD_FIELDS.fetch(key)] = value unless value.nil?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
|
|
3
|
+
module Arara
|
|
4
|
+
module Resources
|
|
5
|
+
class Numbers < BaseResource
|
|
6
|
+
BASE_PATH = "/v1/organizations/me/numbers".freeze
|
|
7
|
+
|
|
8
|
+
# List numbers with plan slot info. GET /v1/organizations/me/numbers
|
|
9
|
+
def list
|
|
10
|
+
@http.get(BASE_PATH)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Update a number. PATCH /v1/organizations/me/numbers/{id}
|
|
14
|
+
def update(id, alias_name: nil, is_default: nil, name: nil, description: nil)
|
|
15
|
+
payload = {
|
|
16
|
+
"alias" => alias_name,
|
|
17
|
+
"isDefault" => is_default,
|
|
18
|
+
"name" => name,
|
|
19
|
+
"description" => description
|
|
20
|
+
}.reject { |_, value| value.nil? }
|
|
21
|
+
@http.patch("#{BASE_PATH}/#{id}", body: payload)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Deactivate a number. DELETE /v1/organizations/me/numbers/{id}
|
|
25
|
+
def delete(id)
|
|
26
|
+
@http.delete("#{BASE_PATH}/#{id}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Request a new dedicated number. POST /v1/organizations/me/numbers/request
|
|
30
|
+
def request(reason: nil, expected_volume: nil, area_code: nil, display_name: nil, profile_picture_url: nil)
|
|
31
|
+
payload = {
|
|
32
|
+
"reason" => reason,
|
|
33
|
+
"expectedVolume" => expected_volume,
|
|
34
|
+
"areaCode" => area_code,
|
|
35
|
+
"displayName" => display_name,
|
|
36
|
+
"profilePictureUrl" => profile_picture_url
|
|
37
|
+
}.reject { |_, value| value.nil? }
|
|
38
|
+
@http.post("#{BASE_PATH}/request", body: payload)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# List number provisioning requests. GET /v1/organizations/me/numbers/requests
|
|
42
|
+
def list_requests
|
|
43
|
+
@http.get("#{BASE_PATH}/requests")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sync a number's health from the provider. POST /v1/organizations/me/numbers/{id}/sync
|
|
47
|
+
def sync(id)
|
|
48
|
+
@http.post("#{BASE_PATH}/#{id}/sync")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get warming recommendations for a number. GET /v1/organizations/me/numbers/{id}/warming
|
|
52
|
+
def warming(id)
|
|
53
|
+
@http.get("#{BASE_PATH}/#{id}/warming")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "base_resource"
|
|
3
|
+
|
|
4
|
+
module Arara
|
|
5
|
+
module Resources
|
|
6
|
+
class OptOuts < BaseResource
|
|
7
|
+
BASE_PATH = "/v1/opt-outs".freeze
|
|
8
|
+
|
|
9
|
+
# List opt-outs. GET /v1/opt-outs (requires an ADMIN key)
|
|
10
|
+
def list
|
|
11
|
+
@http.get(BASE_PATH)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Opt a phone out. POST /v1/opt-outs (requires an ADMIN key)
|
|
15
|
+
def create(phone:, reason: nil)
|
|
16
|
+
payload = { "phone" => phone, "reason" => reason }.reject { |_, value| value.nil? }
|
|
17
|
+
@http.post(BASE_PATH, body: payload)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Get the opt-out of a phone. GET /v1/opt-outs/{phone}
|
|
21
|
+
def get(phone)
|
|
22
|
+
@http.get("#{BASE_PATH}/#{URI.encode_www_form_component(phone)}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Remove the opt-out of a phone. DELETE /v1/opt-outs/{phone}
|
|
26
|
+
def delete(phone)
|
|
27
|
+
@http.delete("#{BASE_PATH}/#{URI.encode_www_form_component(phone)}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
require_relative "../page"
|
|
3
|
+
|
|
4
|
+
module Arara
|
|
5
|
+
module Resources
|
|
6
|
+
class SmartLinks < BaseResource
|
|
7
|
+
BASE_PATH = "/v1/smart-links/whatsapp".freeze
|
|
8
|
+
DEFAULT_PAGE_SIZE = 50
|
|
9
|
+
|
|
10
|
+
# Create a WhatsApp smart link. POST /v1/smart-links/whatsapp
|
|
11
|
+
def create(name:, phone_number:, default_text: nil, qr_code_color: nil)
|
|
12
|
+
payload = {
|
|
13
|
+
"name" => name,
|
|
14
|
+
"phoneNumber" => phone_number,
|
|
15
|
+
"defaultText" => default_text,
|
|
16
|
+
"qrCodeColor" => qr_code_color
|
|
17
|
+
}.reject { |_, value| value.nil? }
|
|
18
|
+
@http.post(BASE_PATH, body: payload)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Update a WhatsApp smart link. PUT /v1/smart-links/whatsapp/{id}
|
|
22
|
+
def update(id, name: nil, default_text: nil, qr_code_color: nil)
|
|
23
|
+
payload = {
|
|
24
|
+
"name" => name,
|
|
25
|
+
"defaultText" => default_text,
|
|
26
|
+
"qrCodeColor" => qr_code_color
|
|
27
|
+
}.reject { |_, value| value.nil? }
|
|
28
|
+
@http.put("#{BASE_PATH}/#{id}", body: payload)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# List WhatsApp smart links. GET /v1/smart-links/whatsapp. Returns an Arara::Page.
|
|
32
|
+
def list(page: 0, size: DEFAULT_PAGE_SIZE)
|
|
33
|
+
Arara::Page.from_data(@http.get(BASE_PATH, params: { "page" => page, "size" => size }))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get smart link click stats. GET /v1/smart-links/whatsapp/{id}/stats
|
|
37
|
+
def stats(id)
|
|
38
|
+
@http.get("#{BASE_PATH}/#{id}/stats")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
require_relative "../page"
|
|
3
|
+
|
|
4
|
+
module Arara
|
|
5
|
+
module Resources
|
|
6
|
+
class Templates < BaseResource
|
|
7
|
+
BASE_PATH = "/v1/templates".freeze
|
|
8
|
+
DEFAULT_PAGE_SIZE = 50
|
|
9
|
+
DEFAULT_PERIOD = "30d".freeze
|
|
10
|
+
|
|
11
|
+
# List templates. GET /v1/templates. Returns an Arara::Page.
|
|
12
|
+
def list(name: nil, status: nil, page: 0, size: DEFAULT_PAGE_SIZE)
|
|
13
|
+
response = @http.get(BASE_PATH, params: { "name" => name, "status" => status, "page" => page, "size" => size })
|
|
14
|
+
Arara::Page.from_data(response)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Find a template by exact name, walking every page of the filtered list. Returns nil when absent.
|
|
18
|
+
def find_by_name(name)
|
|
19
|
+
page_number = 0
|
|
20
|
+
loop do
|
|
21
|
+
page = list(name: name, page: page_number)
|
|
22
|
+
match = page.find { |template| template["name"] == name }
|
|
23
|
+
return match if match
|
|
24
|
+
return nil unless page.next_page?
|
|
25
|
+
|
|
26
|
+
page_number += 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Create a template for Meta approval. POST /v1/templates
|
|
31
|
+
def create(payload)
|
|
32
|
+
@http.post(BASE_PATH, body: payload)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get a template by id (UUID). GET /v1/templates/{id}
|
|
36
|
+
def get(id)
|
|
37
|
+
@http.get("#{BASE_PATH}/#{id}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get template status from provider. GET /v1/templates/{id}/status
|
|
41
|
+
def get_status(id)
|
|
42
|
+
@http.get("#{BASE_PATH}/#{id}/status")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Delete a template by id (UUID). DELETE /v1/templates/{id}
|
|
46
|
+
def delete(id)
|
|
47
|
+
@http.delete("#{BASE_PATH}/#{id}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Analytics of all templates. GET /v1/templates/analytics
|
|
51
|
+
def analytics(period: DEFAULT_PERIOD)
|
|
52
|
+
@http.get("#{BASE_PATH}/analytics", params: { "period" => period })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Analytics of one template. GET /v1/templates/{id}/analytics
|
|
56
|
+
def template_analytics(id, period: DEFAULT_PERIOD)
|
|
57
|
+
@http.get("#{BASE_PATH}/#{id}/analytics", params: { "period" => period })
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require_relative "base_resource"
|
|
2
|
+
require_relative "../page"
|
|
3
|
+
|
|
4
|
+
module Arara
|
|
5
|
+
module Resources
|
|
6
|
+
class Wallet < BaseResource
|
|
7
|
+
DEFAULT_PAGE_SIZE = 20
|
|
8
|
+
|
|
9
|
+
# List wallet transactions. GET /v1/wallet/transactions. Returns an Arara::Page.
|
|
10
|
+
def transactions(page: 0, size: DEFAULT_PAGE_SIZE)
|
|
11
|
+
response = @http.get("/v1/wallet/transactions", params: { "page" => page, "size" => size })
|
|
12
|
+
Arara::Page.from_content(response, page: page, size: size)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Get auto-recharge settings. GET /v1/wallet/auto-recharge
|
|
16
|
+
def get_auto_recharge
|
|
17
|
+
@http.get("/v1/wallet/auto-recharge")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Update auto-recharge settings. PATCH /v1/wallet/auto-recharge
|
|
21
|
+
def update_auto_recharge(enabled: nil, threshold: nil, amount: nil)
|
|
22
|
+
payload = {
|
|
23
|
+
"enabled" => enabled,
|
|
24
|
+
"threshold" => threshold,
|
|
25
|
+
"amount" => amount
|
|
26
|
+
}.reject { |_, value| value.nil? }
|
|
27
|
+
@http.patch("/v1/wallet/auto-recharge", body: payload)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/arara.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ararahq
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- AraraHQ
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'Ruby client for the AraraHQ API: messages, templates, contacts, conversations,
|
|
14
|
+
campaigns, wallet, numbers, smart links and opt-outs. Zero runtime dependencies.'
|
|
15
|
+
email:
|
|
16
|
+
- dev@ararahq.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- arara.gemspec
|
|
25
|
+
- lib/arara.rb
|
|
26
|
+
- lib/arara/client.rb
|
|
27
|
+
- lib/arara/errors.rb
|
|
28
|
+
- lib/arara/http_client.rb
|
|
29
|
+
- lib/arara/page.rb
|
|
30
|
+
- lib/arara/resources/auth.rb
|
|
31
|
+
- lib/arara/resources/base_resource.rb
|
|
32
|
+
- lib/arara/resources/campaigns.rb
|
|
33
|
+
- lib/arara/resources/contacts.rb
|
|
34
|
+
- lib/arara/resources/conversations.rb
|
|
35
|
+
- lib/arara/resources/messages.rb
|
|
36
|
+
- lib/arara/resources/numbers.rb
|
|
37
|
+
- lib/arara/resources/opt_outs.rb
|
|
38
|
+
- lib/arara/resources/smart_links.rb
|
|
39
|
+
- lib/arara/resources/templates.rb
|
|
40
|
+
- lib/arara/resources/wallet.rb
|
|
41
|
+
- lib/arara/version.rb
|
|
42
|
+
homepage: https://ararahq.com
|
|
43
|
+
licenses:
|
|
44
|
+
- MIT
|
|
45
|
+
metadata:
|
|
46
|
+
homepage_uri: https://ararahq.com
|
|
47
|
+
source_code_uri: https://github.com/ararahq/arara-ruby-sdk
|
|
48
|
+
changelog_uri: https://github.com/ararahq/arara-ruby-sdk/blob/main/CHANGELOG.md
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
post_install_message:
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '3.0'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 3.4.19
|
|
66
|
+
signing_key:
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: Official Ruby SDK for the AraraHQ WhatsApp API.
|
|
69
|
+
test_files: []
|