simple_connect-client 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80608e802aa6273143cb24d056f08abc1541dac6223371eae39c2611ed2ab99e
4
- data.tar.gz: 9c8e9130fd84bda6b19eaccf5f57a68ff81dc186070f19ef1834114981dbb8bb
3
+ metadata.gz: fecfb423740267511e5fe8cdabc01f99dae1394be3cd461ceaa0925b01bb8d9c
4
+ data.tar.gz: c132195a36db6b5b5025a328c7602bd855a9cb094ae692548d0325a6da3ef8a9
5
5
  SHA512:
6
- metadata.gz: ad8d5b28164c2598aeb32ee7bf0db42eec12ab15a0fed501d12b4cd9e33dfda11ac69ae24e21be0def68773c1119c40d020d31c0e77aae5b993458a1cc02e7b9
7
- data.tar.gz: 2d578d852fa29a83400e6fecd49918cbddc118ebe7e846f64496e04d79b88ea7c3d3844c4f6f2c22c55900085e3b6872c2b8cc1a0a04810cc9c5b1cc63abfd29
6
+ metadata.gz: bc2c72805d94ac74047a81ba8454ad2e6ee4fac20e016d7fc25682c8be81b09e183d7ee6f19a7fe2957652a3bdc792b7f13aee47a5ed5bf1063c9886f2636b89
7
+ data.tar.gz: dd5904e4da8281014d6412971838fccf1be64ce0d6c781ca54d201078045be27d1172d5d7db322a008b8fee3558bd1ceff61a3e95d33a83402e999b03529b013
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - `SimpleConnect::MessagesClient` — a second, independently-constructible
13
+ entry point for sending WhatsApp **template messages** over the account
14
+ API. Built from `base_url:` + `api_key:` (no HMAC signing secret), so a
15
+ caller with only an account API key — and no integration — can use it.
16
+ Distinct from `SimpleConnect::Client`, which signs Events over HMAC. See
17
+ `docs/adr/0001-messages-use-separate-bearer-client.md`.
18
+ - `messages.deliver(template_name:, recipients:, ...)` — sends one approved
19
+ template to one or many recipients. Optional `language_code:`,
20
+ `sender_phone_number:`, `header:`, `body:`, `buttons:`, and
21
+ `message_group:` are forwarded verbatim in the documented wire shape and
22
+ omitted from the request when not given. Returns a `Result` with a typed
23
+ `Responses::MessageDeliverResponse` (`#queued_messages`, `#errors`,
24
+ `#any_queued?`) on 2xx — partial success (some recipients queued, some
25
+ rejected) is preserved.
26
+ - `messages.detail(message_id)` — GET a previously-sent message's delivery
27
+ status. Returns a `Result` with a typed `Responses::MessageDetailResponse`
28
+ (`#status`, `#recipient`, `#message_group`, `#error`, `#failed?`); `#error`
29
+ is nil unless the message failed.
30
+ - `SimpleConnect::BearerHeaders` — `Authorization: Bearer <api_key>` header
31
+ builder. Shares the `Request` / `Retryable` / `Result` transport with the
32
+ HMAC path unchanged; performs no body signing.
33
+
34
+ ### Compatibility
35
+
36
+ - Scope is **template messages only** — free-form / session messages are not
37
+ supported (they require an open 24h window and are not exposed over the
38
+ JSON API).
39
+ - Targets the existing `POST /api/v1/whatsapp_messages` and
40
+ `GET /api/v1/whatsapp_messages/:id` endpoints — no server changes required.
41
+ - The HMAC Events path (`Client`, `events`, `integrations`) is untouched.
42
+
10
43
  ## [0.3.0] - 2026-04-30
11
44
 
12
45
  ### Added
data/README.md CHANGED
@@ -1,9 +1,22 @@
1
1
  # simple_connect-client
2
2
 
3
- Dependency-free Ruby client for the **SimpleWaConnect** integration endpoints.
4
- Ships domain events (POST), fetches event details (GET), and verifies
5
- integration health all signed with HMAC-SHA256, with built-in retries on
6
- 5xx / network errors.
3
+ Dependency-free Ruby client for the **SimpleWaConnect** API.
4
+
5
+ Two independent entry points:
6
+
7
+ - **`SimpleConnect::Client`** — the **integration** client. Ships domain
8
+ events (POST), fetches event details (GET), and verifies integration
9
+ health — all signed with HMAC-SHA256. For integration providers holding a
10
+ signing `key_id` / `secret`.
11
+ - **`SimpleConnect::MessagesClient`** — the **messages** client. Sends
12
+ WhatsApp template messages (POST) and looks up their delivery status (GET)
13
+ — authenticated with a Bearer account **API key**. For any caller with an
14
+ API key, including third parties with no integration. See
15
+ [docs/adr/0001-messages-use-separate-bearer-client.md](docs/adr/0001-messages-use-separate-bearer-client.md)
16
+ for why these are separate clients.
17
+
18
+ Both share the same transport: built-in retries on 5xx / network errors and
19
+ the same `Result` contract.
7
20
 
8
21
  Stdlib-only at runtime: no Rails, no Faraday, no gems.
9
22
 
@@ -114,6 +127,96 @@ result = SIMPLECONNECT.integrations.verify
114
127
  # result.response_body contains the per-event-flow snapshot
115
128
  ```
116
129
 
130
+ ## Sending template messages
131
+
132
+ `MessagesClient` is a **separate client** from the HMAC `Client` above. It
133
+ authenticates with a Bearer account **API key** (`sk_live_…`) and sends
134
+ **template messages only** — free-form / session messages are out of scope
135
+ (they require an open 24-hour window and aren't exposed over the JSON API).
136
+
137
+ ### Setup
138
+
139
+ ```ruby
140
+ MESSAGES = SimpleConnect::MessagesClient.new(
141
+ base_url: ENV.fetch("SIMPLECONNECT_BASE_URL"), # e.g. https://app.simplewaconnect.com
142
+ api_key: ENV.fetch("SIMPLECONNECT_API_KEY"), # account API key, "sk_live_…"
143
+ logger: Rails.logger # optional
144
+ )
145
+ ```
146
+
147
+ The instance is immutable and thread-safe — share one per `api_key`. If you
148
+ send on behalf of multiple accounts (each with its own number / API key),
149
+ construct one `MessagesClient` per `api_key` and memoize them.
150
+
151
+ ### Deliver a template message
152
+
153
+ ```ruby
154
+ result = MESSAGES.messages.deliver(
155
+ template_name: "order_update",
156
+ language_code: "en_US", # optional → template default
157
+ recipients: [
158
+ { mobile_no: "919999999999", name: "John Doe" } # 1..N recipients; mobile_no required
159
+ ],
160
+ sender_phone_number: "919000000001", # optional → account's first active number
161
+ header: { text: ["Order Update"] }, # forwarded verbatim (see below)
162
+ body: ["John", "ORDER-101"], # positional array OR named hash
163
+ buttons: [
164
+ { index: 2, sub_type: "url", parameters: [{ type: "text", text: "ORDER-101" }] }
165
+ ],
166
+ message_group: "order-101" # optional correlation id
167
+ )
168
+
169
+ if result.success?
170
+ response = result.data # MessageDeliverResponse
171
+ response.queued_messages # => [{ "message_id" =>, "recipient" =>, "status" => }]
172
+ response.errors # => per-recipient rejections, if any
173
+ response.any_queued? # => true when at least one recipient was enqueued
174
+ else
175
+ Rails.logger.warn("send rejected: #{result.data&.message || result.error}")
176
+ end
177
+ ```
178
+
179
+ `template_name` and `recipients` are validated client-side (blank/empty raise
180
+ `ArgumentError` before any HTTP call); each recipient needs `mobile_no`.
181
+ Everything else — template existence, the 24-hour window, opt-out, sender
182
+ validity — is server-authoritative.
183
+
184
+ **Components are forwarded verbatim** in the shape the message API documents.
185
+ Pick the `header` / `body` / `buttons` shape based on the approved template:
186
+
187
+ - `header:` — one of `{ text: [...] }` / `{ text: { name: val } }` /
188
+ `{ image: { link:, caption: } }` / `{ video: {...} }` /
189
+ `{ document: { link:, filename: } }` / `{ audio: { link: } }` /
190
+ `{ location: { latitude:, longitude:, name:, address: } }`. Omit if the
191
+ template has no header (or a text header with no variables).
192
+ - `body:` — positional `["v1", "v2"]` or named `{ customer_name: "John" }`,
193
+ matching the template's `parameter_format`. Omit if no body variables.
194
+ - `buttons:` — only for buttons carrying runtime values (dynamic URL suffix,
195
+ copy-code). Each entry needs `index` (0-based) and `sub_type`.
196
+
197
+ Any argument left `nil` is omitted from the request entirely.
198
+
199
+ ### Look up a message's status
200
+
201
+ ```ruby
202
+ result = MESSAGES.messages.detail(message_id)
203
+
204
+ if result.success?
205
+ msg = result.data # MessageDetailResponse
206
+ msg.status # => "queued" / "sent" / "delivered" / "read" / "failed" / ...
207
+ msg.recipient # => "919999999999"
208
+ msg.message_group # => "order-101" (or nil)
209
+ if msg.failed?
210
+ msg.error # => { "message" => "...", "data" => { ... } }
211
+ end
212
+ end
213
+ ```
214
+
215
+ Like the Events client, message-sending HTTP is synchronous and may retry —
216
+ wrap it in a background job and pass `max_attempts: 1` to avoid stacking the
217
+ queue's retries on the library's (see "Run in a background job" below; the
218
+ same guidance applies to `MessagesClient`).
219
+
117
220
  ## Return value — `SimpleConnect::Result`
118
221
 
119
222
  Every resource call returns a `Result` struct with:
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ module Api
5
+ # Messages resource. Sends WhatsApp template messages over Bearer auth via
6
+ # the account API (POST /api/v1/whatsapp_messages). Holds the shared
7
+ # Request and the base URI (host); owns the fixed endpoint path.
8
+ class Messages
9
+ include ResponseAttachment
10
+
11
+ MESSAGES_PATH = "/api/v1/whatsapp_messages"
12
+ MESSAGE_TYPE_TEMPLATE = "template"
13
+
14
+ def initialize(request:, base_uri:)
15
+ @request = request
16
+ @base_uri = base_uri
17
+ end
18
+
19
+ def deliver(template_name:, recipients:, language_code: nil, sender_phone_number: nil,
20
+ header: nil, body: nil, buttons: nil, message_group: nil)
21
+ raise ArgumentError, "template_name is required" if template_name.to_s.strip.empty?
22
+
23
+ entry = {
24
+ "message_type" => MESSAGE_TYPE_TEMPLATE,
25
+ "template_name" => template_name,
26
+ "recipients" => normalize_recipients(recipients),
27
+ "language_code" => language_code,
28
+ "sender_phone_number" => sender_phone_number,
29
+ "header" => header,
30
+ "body" => body,
31
+ "buttons" => buttons,
32
+ "message_group" => message_group
33
+ }.compact
34
+
35
+ payload = { "messages" => [entry] }.to_json
36
+ attach_response(@request.post(collection_uri, body: payload), Responses::MessageDeliverResponse)
37
+ end
38
+
39
+ def detail(message_id)
40
+ raise ArgumentError, "message_id is required" if message_id.to_s.strip.empty?
41
+
42
+ attach_response(@request.get(member_uri(message_id)), Responses::MessageDetailResponse)
43
+ end
44
+
45
+ private
46
+
47
+ def collection_uri
48
+ @collection_uri ||= URI.join(@base_uri.to_s, MESSAGES_PATH)
49
+ end
50
+
51
+ def member_uri(message_id)
52
+ URI.join(@base_uri.to_s, "#{MESSAGES_PATH}/#{URI.encode_www_form_component(message_id.to_s)}")
53
+ end
54
+
55
+ def normalize_recipients(recipients)
56
+ raise ArgumentError, "recipients must be a non-empty array" unless recipients.is_a?(Array) && recipients.any?
57
+
58
+ recipients.map do |recipient|
59
+ row = stringify_keys(recipient)
60
+ raise ArgumentError, "mobile_no is required for each recipient" if row["mobile_no"].to_s.strip.empty?
61
+
62
+ row
63
+ end
64
+ end
65
+
66
+ def stringify_keys(hash)
67
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ # Builds Bearer-auth request headers (Content-Type + User-Agent +
5
+ # Authorization) for the account Message API. Same #build_for(body)
6
+ # interface as Headers, so Request can use either interchangeably — but
7
+ # this one performs no body signing. One instance per MessagesClient; holds
8
+ # the api_key so callers never see it.
9
+ class BearerHeaders
10
+ def initialize(api_key:, user_agent: nil)
11
+ @api_key = api_key.to_s
12
+ @user_agent = (user_agent || Headers.default_user_agent).to_s
13
+ end
14
+
15
+ def build_for(_body)
16
+ {
17
+ "Content-Type" => "application/json",
18
+ "User-Agent" => @user_agent,
19
+ "Authorization" => "Bearer #{@api_key}"
20
+ }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ # Entry point for the account Message API. Assembles the Bearer-auth
5
+ # transport once, then exposes a single resource object:
6
+ #
7
+ # MSG = SimpleConnect::MessagesClient.new(base_url:, api_key:)
8
+ # MSG.messages.deliver(template_name:, recipients:, ...)
9
+ #
10
+ # Distinct from SimpleConnect::Client (which signs Events over HMAC): a
11
+ # caller holding only an account api_key — with no integration — uses this.
12
+ # See docs/adr/0001-messages-use-separate-bearer-client.md.
13
+ class MessagesClient
14
+ DEFAULT_TIMEOUT = 10
15
+
16
+ attr_reader :messages
17
+
18
+ # @param base_url [String] the app host, e.g.
19
+ # "https://app.simplewaconnect.com". The fixed message path is appended
20
+ # by the resource.
21
+ # @param api_key [String] account API key (`sk_live_…`) sent as a Bearer
22
+ # token.
23
+ # @param timeout [Integer] HTTP read/open timeout in seconds.
24
+ # @param logger [#info, #warn, nil] optional logger for transport events.
25
+ # @param max_attempts [Integer] total HTTP attempts (including the first).
26
+ # Default 3. Pass `1` to disable library retries. Mutually exclusive
27
+ # with `retryable:`.
28
+ # @param retryable [SimpleConnect::Retryable, nil] custom retry policy.
29
+ # Mutually exclusive with `max_attempts:`.
30
+ # @param user_agent [String, nil] override the default User-Agent header.
31
+ def initialize(base_url:, api_key:,
32
+ timeout: DEFAULT_TIMEOUT, logger: nil,
33
+ max_attempts: nil, retryable: nil, user_agent: nil)
34
+ raise ConfigurationError, "base_url is required" if base_url.to_s.strip.empty?
35
+ raise ConfigurationError, "api_key is required" if api_key.to_s.strip.empty?
36
+
37
+ if max_attempts && retryable
38
+ raise ConfigurationError,
39
+ "pass either `max_attempts:` or `retryable:`, not both"
40
+ end
41
+
42
+ base_uri = URI.parse(base_url)
43
+ request = Request.new(
44
+ headers: BearerHeaders.new(api_key: api_key, user_agent: user_agent),
45
+ timeout: timeout,
46
+ logger: logger,
47
+ retryable: retryable || Retryable.new(max_attempts: max_attempts || Retryable::DEFAULT_MAX_ATTEMPTS)
48
+ )
49
+ @messages = Api::Messages.new(request: request, base_uri: base_uri)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ module Responses
5
+ # Wraps the `messages.deliver` acknowledgement.
6
+ #
7
+ # Server returns (200, or 422 when nothing queued):
8
+ # { "success": true,
9
+ # "queued_messages": [{ "message_id":, "recipient":, "status": }],
10
+ # "errors": [ ... ] }
11
+ #
12
+ # Partial success is possible — some recipients queued, some rejected —
13
+ # so consumers read both `#queued_messages` and `#errors`.
14
+ class MessageDeliverResponse
15
+ attr_reader :queued_messages, :errors
16
+
17
+ def initialize(json)
18
+ @json = json.is_a?(Hash) ? json : {}
19
+ @queued_messages = @json["queued_messages"] || []
20
+ @errors = @json["errors"] || []
21
+ end
22
+
23
+ def any_queued?
24
+ @queued_messages.any?
25
+ end
26
+
27
+ def to_h
28
+ @json.dup
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ module Responses
5
+ # Wraps the `messages.detail` lookup (GET /api/v1/whatsapp_messages/:id).
6
+ #
7
+ # Server returns:
8
+ # { "message_id":, "status":, "message_group":, "recipient": }
9
+ # plus, only when the message failed:
10
+ # "error": { "message":, "data": }
11
+ #
12
+ # `#error` is nil unless the message failed; `#failed?` reflects that.
13
+ class MessageDetailResponse
14
+ attr_reader :message_id, :status, :recipient, :message_group, :error
15
+
16
+ def initialize(json)
17
+ @json = json.is_a?(Hash) ? json : {}
18
+ @message_id = @json["message_id"]
19
+ @status = @json["status"]
20
+ @recipient = @json["recipient"]
21
+ @message_group = @json["message_group"]
22
+ @error = @json["error"]
23
+ end
24
+
25
+ def failed?
26
+ !@error.nil?
27
+ end
28
+
29
+ def to_h
30
+ @json.dup
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleConnect
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -14,14 +14,19 @@ require_relative "simple_connect/responses/message_response"
14
14
  require_relative "simple_connect/responses/event_response"
15
15
  require_relative "simple_connect/responses/verify_response"
16
16
  require_relative "simple_connect/responses/deliver_response"
17
+ require_relative "simple_connect/responses/message_deliver_response"
18
+ require_relative "simple_connect/responses/message_detail_response"
17
19
  require_relative "simple_connect/result"
18
20
  require_relative "simple_connect/retryable"
19
21
  require_relative "simple_connect/headers"
22
+ require_relative "simple_connect/bearer_headers"
20
23
  require_relative "simple_connect/request"
21
24
  require_relative "simple_connect/api/response_attachment"
22
25
  require_relative "simple_connect/api/events"
23
26
  require_relative "simple_connect/api/integrations"
27
+ require_relative "simple_connect/api/messages"
24
28
  require_relative "simple_connect/client"
29
+ require_relative "simple_connect/messages_client"
25
30
 
26
31
  # Top-level namespace. See SimpleConnect::Client for usage.
27
32
  module SimpleConnect
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_connect-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ramkrishan Patidar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-30 00:00:00.000000000 Z
11
+ date: 2026-06-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -83,14 +83,19 @@ files:
83
83
  - lib/simple_connect.rb
84
84
  - lib/simple_connect/api/events.rb
85
85
  - lib/simple_connect/api/integrations.rb
86
+ - lib/simple_connect/api/messages.rb
86
87
  - lib/simple_connect/api/response_attachment.rb
88
+ - lib/simple_connect/bearer_headers.rb
87
89
  - lib/simple_connect/client.rb
88
90
  - lib/simple_connect/errors.rb
89
91
  - lib/simple_connect/headers.rb
92
+ - lib/simple_connect/messages_client.rb
90
93
  - lib/simple_connect/request.rb
91
94
  - lib/simple_connect/responses/deliver_response.rb
92
95
  - lib/simple_connect/responses/error_response.rb
93
96
  - lib/simple_connect/responses/event_response.rb
97
+ - lib/simple_connect/responses/message_deliver_response.rb
98
+ - lib/simple_connect/responses/message_detail_response.rb
94
99
  - lib/simple_connect/responses/message_response.rb
95
100
  - lib/simple_connect/responses/verify_response.rb
96
101
  - lib/simple_connect/result.rb