whatsapp_sdk 1.0.5 → 1.2.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: 17de6b59f97c39b0d49c911d4e153d241f60d7f92727a10ec895ff4dc4d9822b
4
- data.tar.gz: f2f25357ffa9d35972e157f57abfaa4a3ac2b2a9debb0f86fdb15230581c6ce6
3
+ metadata.gz: 6e1bc509c72a21009421ece7489cc79d178abec74db4445d84cf579fe374d5d3
4
+ data.tar.gz: b547704f8654b49e389547d27014089fdf1583583e57c6f567bf1193dbfc72aa
5
5
  SHA512:
6
- metadata.gz: 1ef8af8f454441f188633dab7295b20da476d8692af1bf6602032b7504c08d1931bc9fbb9544452f981b3b58111ed0bec034de6138540c3445d6379a50f813ac
7
- data.tar.gz: ead1a38b14a8d5d16539e61ba4b65c36c6652525ca10d7aab554dd3b83a8cd1b11fff52dc38e0c121f574db108ce3ea64546bf62a386b46078dfc369dd559710
6
+ metadata.gz: f302ce6f5126585be265c8a0bdcc7c7681cd27e6838ce6c961a0b196eb66ba0cc3522c87407cbc69af7d95f8adeca02f7dbcf76ee646fddff49e3e50ba6c62b3
7
+ data.tar.gz: d7a97fa6bc6de65711c1d1698cd38863650dfc7f2dd0ef5ec743d2c938e15bca6511f6481c5fd72ee2972ec258466c9066817a9a3ea378f98c0d2db26a05e186
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Unreleased
2
2
 
3
+ # v 1.2.0
4
+ - Expose Graph media upload sessions through the configured client @oitedi [199](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/199)
5
+ - Support BSUID recipients and preserve user IDs in message responses @oitedi [198](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/198)
6
+ - Add voice message support to audio sends @oitedi [197](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/197)
7
+ - Guard `GenericErrorResponse.build_from_response` against non-Hash input @afonso-aguas [195](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/195)
8
+ - Fix false-positive `HttpResponseError` on bodies containing "error" substring @afonso-aguas [194](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/194)
9
+
10
+ # v 1.1.0
11
+ - Add support for pagination in template_analytics with 'after' cursor @osvaldo-santos [190](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/190)
12
+ - Add template_analytics feature @osvaldo-santos [189](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/189)
13
+ - Add Business Account API support @osvaldo-santos [187](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/187)
14
+
3
15
  # v 1.0.5
4
16
  - Added GET template endpoint. @osvaldo-santos [185](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/185)
5
17
  - Adding support for named paremeters in Templates API. @osvaldo-santos [184](https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/pull/184)
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- whatsapp_sdk (1.0.5)
4
+ whatsapp_sdk (1.2.0)
5
5
  faraday (~> 2.0, > 2.0.1)
6
6
  faraday-multipart (~> 1)
7
7
  zeitwerk (~> 2)
@@ -9,14 +9,14 @@ PATH
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- addressable (2.8.1)
13
- public_suffix (>= 2.0.2, < 6.0)
12
+ addressable (2.9.0)
13
+ public_suffix (>= 2.0.2, < 8.0)
14
14
  ast (2.4.2)
15
15
  base64 (0.2.0)
16
16
  coderay (1.1.3)
17
17
  crack (0.4.5)
18
18
  rexml
19
- faraday (2.14.0)
19
+ faraday (2.14.1)
20
20
  faraday-net_http (>= 2.0, < 3.5)
21
21
  json
22
22
  logger
@@ -25,10 +25,10 @@ GEM
25
25
  faraday-net_http (3.4.2)
26
26
  net-http (~> 0.5)
27
27
  hashdiff (1.0.1)
28
- json (2.18.0)
28
+ json (2.19.9)
29
29
  logger (1.7.0)
30
30
  method_source (1.0.0)
31
- minitest (5.16.1)
31
+ minitest (5.27.0)
32
32
  mocha (1.14.0)
33
33
  multipart-post (2.4.1)
34
34
  net-http (0.9.1)
@@ -41,7 +41,7 @@ GEM
41
41
  method_source (~> 1.0)
42
42
  pry-nav (1.0.0)
43
43
  pry (>= 0.9.10, < 0.15)
44
- public_suffix (5.0.0)
44
+ public_suffix (5.1.1)
45
45
  rainbow (3.1.1)
46
46
  rake (12.3.3)
47
47
  regexp_parser (2.5.0)
@@ -71,13 +71,14 @@ GEM
71
71
  addressable (>= 2.8.0)
72
72
  crack (>= 0.3.2)
73
73
  hashdiff (>= 0.4.0, < 2.0.0)
74
- zeitwerk (2.7.4)
74
+ zeitwerk (2.7.5)
75
75
 
76
76
  PLATFORMS
77
77
  arm64-darwin-21
78
78
  arm64-darwin-22
79
79
  arm64-darwin-23
80
80
  arm64-darwin-24
81
+ arm64-darwin-25
81
82
  x86_64-darwin-19
82
83
  x86_64-darwin-21
83
84
  x86_64-linux
data/README.md CHANGED
@@ -17,6 +17,7 @@ Send stickers, messages, audio, videos, locations, react and reply to messages o
17
17
  - [APIs](#apis)
18
18
  - [Templates](#templates)
19
19
  - [Business Profile API](#business-profile-api)
20
+ - [Business Account API](#business-account-api)
20
21
  - [Phone numbers API](#phone-numbers-api)
21
22
  - [Media API](#media-api)
22
23
  - [Messages API](#messages-api)
@@ -207,6 +208,12 @@ client.templates.create(
207
208
 
208
209
  # Delete a template
209
210
  client.templates.delete(business_id: BUSINESS_ID, name: "my_name") # delete by name
211
+
212
+ # Get templates analytics
213
+ client.templates.template_analytics(
214
+ business_id: BUSINESS_ID, start_timestamp: 1767236400,
215
+ end_timestamp: 1767322799, template_ids: [id]
216
+ )
210
217
  ```
211
218
  </details>
212
219
 
@@ -223,6 +230,22 @@ client.business_profiles.update(phone_number_id: SENDER_ID, params: { about: "A
223
230
  ```
224
231
  </details>
225
232
 
233
+ ### Business Account API
234
+ <details>
235
+
236
+ ```ruby
237
+ # Get the details of your business account
238
+ client.business_accounts.get(BUSINESS_ID)
239
+
240
+ # Get some fields of your business account
241
+ client.business_accounts.get(BUSINESS_ID, fields: ["id", "name"])
242
+
243
+ # Update your business account
244
+ # Note: You can only update the `name` and `timezone_id` fields of your business account.
245
+ client.business_accounts.update(business_id: BUSINESS_ID, params: { name: "My new business name", timezone_id: "1" })
246
+ ```
247
+ </details>
248
+
226
249
  ### Phone numbers API
227
250
 
228
251
  <details>
@@ -512,6 +535,54 @@ If the API response is still `success`, but the message is not delivered:
512
535
 
513
536
  Note: Sometimes the messages are delayed; see [Meta documentation](https://developers.facebook.com/docs/whatsapp/on-premises/guides/send-message-performance#delays).
514
537
 
538
+
539
+ ### Voice messages
540
+
541
+ Pass `voice: true` to `send_audio` with an uploaded `audio_id` or hosted `link`.
542
+ Voice messages require an Ogg file encoded with Opus; the SDK does not transcode media.
543
+ Omitting `voice` (or passing `false`) sends basic audio.
544
+
545
+ ```ruby
546
+ client.messages.send_audio(sender_id: sender_id, recipient_number: recipient_number,
547
+ audio_id: media_id, voice: true)
548
+ ```
549
+
550
+ See [Meta's audio message documentation](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/audio-messages/).
551
+
552
+ ### Upload handles for templates and profile photos
553
+
554
+ Use Graph upload sessions when an endpoint requires a media handle instead of a WhatsApp media ID:
555
+
556
+ ```ruby
557
+ session = client.media.create_upload_session(app_id: app_id, file_path: "header.png", type: "image/png")
558
+ upload = client.media.upload_file_to_session(session_id: session.fetch("id"), file_path: "header.png")
559
+ handle = upload.fetch("h")
560
+ ```
561
+
562
+ Both methods use the client's configured API version and token. An optional `access_token:`
563
+ overrides the token for that request only. They return the Graph response hashes and raise
564
+ `WhatsappSdk::Api::Responses::HttpResponseError` on Graph errors, like the other APIs.
565
+ Missing files or directories raise `WhatsappSdk::Api::Medias::FileNotFoundError`.
566
+ The upload reads the entire file into memory and starts at offset zero; automatic retries and
567
+ resuming interrupted uploads are not included.
568
+ See [Meta's upload guide](https://developers.facebook.com/docs/graph-api/guides/upload).
569
+
570
+ ### Business-scoped user IDs (BSUIDs)
571
+
572
+ All message sends accept `recipient:` as an alternative to `recipient_number:`.
573
+ Use the complete BSUID (or parent BSUID), including its country prefix:
574
+
575
+ ```ruby
576
+ response = client.messages.send_text(sender_id: sender_id, recipient: "US.123abc", message: "Hello")
577
+ response.contacts.first.user_id # => "US.123abc"
578
+ response.contacts.first.wa_id # => nil when Meta omits the phone number
579
+ ```
580
+
581
+ Phone numbers take precedence when both destinations are supplied. Missing or blank
582
+ selected destinations raise `WhatsappSdk::Resource::Errors::MissingArgumentError` before sending.
583
+ Authentication templates require a phone number; the SDK does not infer template category from its name or buttons.
584
+ See [Meta's BSUID documentation](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/).
585
+
515
586
  ## Development
516
587
 
517
588
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
data/example.rb CHANGED
@@ -142,6 +142,12 @@ end
142
142
 
143
143
  # client.templates.delete(business_id: BUSINESS_ID, name: "name2", hsm_id: "243213188351928") # delete by name and id
144
144
 
145
+ ## Get template analytics
146
+ analytics = client.templates.template_analytics(
147
+ business_id: BUSINESS_ID, start_timestamp: 1767236400,
148
+ end_timestamp: 1767322799, template_ids: [id, new_template.id]
149
+ )
150
+ puts "GET template analytics: #{analytics.records.first}"
145
151
 
146
152
 
147
153
  # ############################## Business API ##############################
@@ -158,6 +164,22 @@ run_and_catch_error("Update business profile") do
158
164
  updated_bp = client.business_profiles.update(phone_number_id: SENDER_ID, params: { about: "A cool business" } )
159
165
  end
160
166
 
167
+ # ############################## Business Account API ##############################
168
+ puts "\n\n\n ------------------ Testing Business Account API -----------------------"
169
+
170
+ business_account = client.business_accounts.get(BUSINESS_ID)
171
+ puts "GET Business Account by id: #{business_account.name}"
172
+
173
+ business_account = client.business_accounts.get(BUSINESS_ID, fields: %w[id name account_review_status message_template_namespace] )
174
+ puts "GET Business Account with fields by id: #{business_account.name}, #{business_account.account_review_status}, #{business_account.message_template_namespace}"
175
+
176
+ updated_ba = client.business_accounts.update(business_id: BUSINESS_ID, params: { name: 'A cool updated business' } )
177
+ puts "UPDATE Business Account by id: #{updated_ba} }"
178
+
179
+ run_and_catch_error("Update business account") do
180
+ # message_template_namespace can't be set
181
+ client.business_accounts.update(business_id: BUSINESS_ID, params: { message_template_namespace: "namespace" } )
182
+ end
161
183
 
162
184
 
163
185
  ############################## Phone Numbers API ##############################
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request"
4
+ require_relative "../resource/business_account"
5
+
6
+ module WhatsappSdk
7
+ module Api
8
+ class BusinessAccount < Request
9
+ DEFAULT_FIELDS = %w[id name timezone_id message_template_namespace account_review_status
10
+ business_verification_status country ownership_type primary_business_location]
11
+ .join(',')
12
+ .freeze
13
+
14
+ # Get the details of business account.
15
+ #
16
+ # @param business_id [Integer] Business Account Id.
17
+ # @param fields [Array<String>] Optional list of fields to include in the response. Defaults to 'id,name'.
18
+ # @return [Resource::BusinessAccount] Response object.
19
+ def get(business_id, fields: nil)
20
+ fields = if fields
21
+ fields.join(',')
22
+ else
23
+ DEFAULT_FIELDS
24
+ end
25
+
26
+ response = send_request(
27
+ http_method: "get",
28
+ endpoint: "#{business_id}?fields=#{fields}"
29
+ )
30
+
31
+ Resource::BusinessAccount.from_hash(response)
32
+ end
33
+
34
+ # Update the details of business account.
35
+ #
36
+ # @param business_id [Integer] Business Account Id.
37
+ # @param params [Hash] Params to update. The possible attributes to update are: `name`, `timezone_id`.
38
+ # @return [Boolean] Whether the update was successful.
39
+ def update(business_id:, params:)
40
+ raise ArgumentError, "Params must be a hash." unless params.is_a?(Hash)
41
+
42
+ # Only name and timezone_id can be updated. If other keys are present, they will be ignored.
43
+ filtered_params = params.transform_keys(&:to_sym).slice(:name, :timezone_id)
44
+
45
+ if filtered_params.empty?
46
+ raise ArgumentError, "No valid parameters provided. Only 'name' and 'timezone_id' can be updated."
47
+ end
48
+
49
+ response = send_request(
50
+ http_method: "post",
51
+ endpoint: business_id.to_s,
52
+ params: filtered_params
53
+ )
54
+
55
+ Api::Responses::SuccessResponse.success_response?(response: response)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -7,7 +7,7 @@ module WhatsappSdk
7
7
  module Api
8
8
  class Client
9
9
  API_VERSIONS = [
10
- 'v24.0', 'v23.0', 'v22.0', 'v21.0', 'v20.0', 'v19.0', 'v18.0', 'v17.0', 'v16.0', 'v15.0', 'v14.0',
10
+ 'v25.0', 'v24.0', 'v23.0', 'v22.0', 'v21.0', 'v20.0', 'v19.0', 'v18.0', 'v17.0', 'v16.0', 'v15.0', 'v14.0',
11
11
  'v13.0', 'v12.0', 'v11.0', 'v10.0', 'v9.0', 'v8.0', 'v7.0', 'v6.0', 'v5.0', 'v4.0',
12
12
  'v3.3', 'v3.2', 'v3.1', 'v3.0', 'v2.12', 'v2.11', 'v2.10', 'v2.9', 'v2.8', 'v2.7',
13
13
  'v2.6', 'v2.5', 'v2.4', 'v2.3', 'v2.2', 'v2.1'
@@ -43,6 +43,10 @@ module WhatsappSdk
43
43
  @business_profiles ||= WhatsappSdk::Api::BusinessProfile.new(self)
44
44
  end
45
45
 
46
+ def business_accounts
47
+ @business_accounts ||= WhatsappSdk::Api::BusinessAccount.new(self)
48
+ end
49
+
46
50
  def templates
47
51
  @templates ||= WhatsappSdk::Api::Templates.new(self)
48
52
  end
@@ -54,13 +58,13 @@ module WhatsappSdk
54
58
 
55
59
  response = faraday_request.public_send(http_method, endpoint, request_params(params, headers), headers)
56
60
 
57
- if response.status > 499 || Api::Responses::GenericErrorResponse.response_error?(response: response.body)
58
- raise Api::Responses::HttpResponseError.new(http_status: response.status, body: JSON.parse(response.body))
59
- end
61
+ parsed_body = parse_response_body(response.body)
60
62
 
61
- return nil if response.body == ""
63
+ if response.status > 499 || Api::Responses::GenericErrorResponse.response_error?(response: parsed_body)
64
+ raise Api::Responses::HttpResponseError.new(http_status: response.status, body: parsed_body)
65
+ end
62
66
 
63
- JSON.parse(response.body)
67
+ parsed_body
64
68
  end
65
69
 
66
70
  def download_file(url:, content_type_header:, file_path: nil)
@@ -81,6 +85,12 @@ module WhatsappSdk
81
85
 
82
86
  private
83
87
 
88
+ def parse_response_body(body)
89
+ return nil if body.nil? || body.empty?
90
+
91
+ JSON.parse(body)
92
+ end
93
+
84
94
  def request_params(params, headers)
85
95
  return params.to_json if params.is_a?(Hash) && headers['Content-Type'] == 'application/json'
86
96
 
@@ -117,6 +117,33 @@ module WhatsappSdk
117
117
  Api::Responses::SuccessResponse.success_response?(response: response)
118
118
  end
119
119
 
120
+ # Create a Graph upload session for template media or a business profile photo.
121
+ # Returns the raw response containing the upload session "id".
122
+ # Uses the client's API version and token unless access_token is supplied.
123
+ def create_upload_session(app_id:, file_path:, type:, access_token: nil)
124
+ raise FileNotFoundError.new(file_path: file_path) unless File.file?(file_path)
125
+
126
+ headers = { 'Content-Type' => 'application/json' }
127
+ headers['Authorization'] = "Bearer #{access_token}" if access_token
128
+ send_request(
129
+ endpoint: "./#{app_id}/uploads",
130
+ params: { file_name: File.basename(file_path), file_length: File.size(file_path), file_type: type },
131
+ headers: headers
132
+ )
133
+ end
134
+
135
+ # Upload the complete file to a Graph upload session, starting at offset zero.
136
+ # Returns the raw response containing the reusable media handle "h".
137
+ # This does not resume partial uploads or retry failed requests.
138
+ def upload_file_to_session(session_id:, file_path:, access_token: nil)
139
+ raise FileNotFoundError.new(file_path: file_path) unless File.file?(file_path)
140
+
141
+ headers = { 'Content-Type' => 'application/octet-stream', 'file_offset' => '0' }
142
+ headers['Authorization'] = "Bearer #{access_token}" if access_token
143
+ # ponytail: buffers the whole file; stream bytes if large uploads become a requirement.
144
+ send_request(endpoint: "./#{session_id}", params: File.binread(file_path), headers: headers)
145
+ end
146
+
120
147
  private
121
148
 
122
149
  def map_media_type_to_content_type_header(media_type)
@@ -13,8 +13,9 @@ module WhatsappSdk
13
13
  # @param recipient_number [Integer] Recipient' Phone number.
14
14
  # @param message [String] Text to send.
15
15
  # @param message_id [String] The id of the message to reply to.
16
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
16
17
  # @return [MessageDataResponse] Response object.
17
- def send_text(sender_id:, recipient_number:, message:, message_id: nil)
18
+ def send_text(sender_id:, message:, recipient_number: nil, message_id: nil, recipient: nil)
18
19
  params = {
19
20
  messaging_product: "whatsapp",
20
21
  to: recipient_number,
@@ -23,6 +24,7 @@ module WhatsappSdk
23
24
  text: { body: message }
24
25
  }
25
26
  params[:context] = { message_id: message_id } if message_id
27
+ apply_recipient!(params, recipient_number, recipient)
26
28
 
27
29
  response = send_request(
28
30
  endpoint: endpoint(sender_id),
@@ -42,9 +44,10 @@ module WhatsappSdk
42
44
  # @param name [String] Location name.
43
45
  # @param address [String] Location address.
44
46
  # @param message_id [String] The id of the message to reply to.
47
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
45
48
  # @return [MessageDataResponse] Response object.
46
49
  def send_location(
47
- sender_id:, recipient_number:, longitude:, latitude:, name:, address:, message_id: nil
50
+ sender_id:, longitude:, latitude:, name:, address:, recipient_number: nil, message_id: nil, recipient: nil
48
51
  )
49
52
  params = {
50
53
  messaging_product: "whatsapp",
@@ -59,6 +62,7 @@ module WhatsappSdk
59
62
  }
60
63
  }
61
64
  params[:context] = { message_id: message_id } if message_id
65
+ apply_recipient!(params, recipient_number, recipient)
62
66
 
63
67
  response = send_request(
64
68
  endpoint: endpoint(sender_id),
@@ -77,9 +81,10 @@ module WhatsappSdk
77
81
  # @param link [String] Image link.
78
82
  # @param caption [String] Image caption.
79
83
  # @param message_id [String] The id of the message to reply to.
84
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
80
85
  # @return [MessageDataResponse] Response object.
81
86
  def send_image(
82
- sender_id:, recipient_number:, image_id: nil, link: nil, caption: "", message_id: nil
87
+ sender_id:, recipient_number: nil, image_id: nil, link: nil, caption: "", message_id: nil, recipient: nil
83
88
  )
84
89
  raise Resource::Errors::MissingArgumentError, "image_id or link is required" if !image_id && !link
85
90
 
@@ -95,6 +100,7 @@ module WhatsappSdk
95
100
  { id: image_id, caption: caption }
96
101
  end
97
102
  params[:context] = { message_id: message_id } if message_id
103
+ apply_recipient!(params, recipient_number, recipient)
98
104
 
99
105
  response = send_request(
100
106
  endpoint: endpoint(sender_id),
@@ -112,8 +118,12 @@ module WhatsappSdk
112
118
  # @param audio_id [String] Audio ID.
113
119
  # @param link [String] Audio link.
114
120
  # @param message_id [String] The id of the message to reply to.
121
+ # @param voice [Boolean] Send an Ogg/Opus voice message instead of basic audio. Defaults to false.
122
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
115
123
  # @return [MessageDataResponse] Response object.
116
- def send_audio(sender_id:, recipient_number:, audio_id: nil, link: nil, message_id: nil)
124
+ def send_audio(
125
+ sender_id:, recipient_number: nil, audio_id: nil, link: nil, message_id: nil, voice: false, recipient: nil
126
+ )
117
127
  raise Resource::Errors::MissingArgumentError, "audio_id or link is required" if !audio_id && !link
118
128
 
119
129
  params = {
@@ -122,8 +132,9 @@ module WhatsappSdk
122
132
  recipient_type: "individual",
123
133
  type: "audio"
124
134
  }
125
- params[:audio] = link ? { link: link } : { id: audio_id }
135
+ params[:audio] = link ? { link: link, voice: voice } : { id: audio_id, voice: voice }
126
136
  params[:context] = { message_id: message_id } if message_id
137
+ apply_recipient!(params, recipient_number, recipient)
127
138
 
128
139
  response = send_request(
129
140
  endpoint: endpoint(sender_id),
@@ -143,9 +154,10 @@ module WhatsappSdk
143
154
  # @param link [String] Image link.
144
155
  # @param caption [String] Image caption.
145
156
  # @param message_id [String] The id of the message to reply to.
157
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
146
158
  # @return [MessageDataResponse] Response object.
147
159
  def send_video(
148
- sender_id:, recipient_number:, video_id: nil, link: nil, caption: "", message_id: nil
160
+ sender_id:, recipient_number: nil, video_id: nil, link: nil, caption: "", message_id: nil, recipient: nil
149
161
  )
150
162
  raise Resource::Errors::MissingArgumentError, "video_id or link is required" if !video_id && !link
151
163
 
@@ -161,6 +173,7 @@ module WhatsappSdk
161
173
  { id: video_id, caption: caption }
162
174
  end
163
175
  params[:context] = { message_id: message_id } if message_id
176
+ apply_recipient!(params, recipient_number, recipient)
164
177
 
165
178
  response = send_request(
166
179
  endpoint: endpoint(sender_id),
@@ -179,9 +192,11 @@ module WhatsappSdk
179
192
  # @param link [String] Image link.
180
193
  # @param caption [String] Image caption.
181
194
  # @param message_id [String] The id of the message to reply to.
195
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
182
196
  # @return [MessageDataResponse] Response object.
183
197
  def send_document(
184
- sender_id:, recipient_number:, document_id: nil, link: nil, caption: "", message_id: nil, filename: nil
198
+ sender_id:, recipient_number: nil, document_id: nil, link: nil, caption: "", message_id: nil,
199
+ filename: nil, recipient: nil
185
200
  )
186
201
  if !document_id && !link
187
202
  raise Resource::Errors::MissingArgumentError,
@@ -201,6 +216,7 @@ module WhatsappSdk
201
216
  end
202
217
  params[:document] = params[:document].merge({ filename: filename }) if filename
203
218
  params[:context] = { message_id: message_id } if message_id
219
+ apply_recipient!(params, recipient_number, recipient)
204
220
 
205
221
  response = send_request(
206
222
  endpoint: endpoint(sender_id),
@@ -211,15 +227,16 @@ module WhatsappSdk
211
227
  Api::Responses::MessageDataResponse.build_from_response(response: response)
212
228
  end
213
229
 
214
- # Send a document.
230
+ # Send a sticker.
215
231
  #
216
232
  # @param sender_id [Integer] Sender' phone number.
217
233
  # @param recipient_number [Integer] Recipient' Phone number.
218
234
  # @param sticker_id [String] The sticker ID.
219
235
  # @param link [String] Image link.
220
236
  # @param message_id [String] The id of the message to reply to.
237
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
221
238
  # @return [MessageDataResponse] Response object.
222
- def send_sticker(sender_id:, recipient_number:, sticker_id: nil, link: nil, message_id: nil)
239
+ def send_sticker(sender_id:, recipient_number: nil, sticker_id: nil, link: nil, message_id: nil, recipient: nil)
223
240
  raise Resource::Errors::MissingArgumentError, "sticker or link is required" if !sticker_id && !link
224
241
 
225
242
  params = {
@@ -230,6 +247,7 @@ module WhatsappSdk
230
247
  }
231
248
  params[:sticker] = link ? { link: link } : { id: sticker_id }
232
249
  params[:context] = { message_id: message_id } if message_id
250
+ apply_recipient!(params, recipient_number, recipient)
233
251
 
234
252
  response = send_request(
235
253
  endpoint: endpoint(sender_id),
@@ -248,9 +266,10 @@ module WhatsappSdk
248
266
  # @param contacts [Array<Contact>] Contacts.
249
267
  # @param contacts_json [Json] Contacts.
250
268
  # @param message_id [String] The id of the message to reply to.
269
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
251
270
  # @return [MessageDataResponse] Response object.
252
271
  def send_contacts(
253
- sender_id:, recipient_number:, contacts: nil, contacts_json: {}, message_id: nil
272
+ sender_id:, recipient_number: nil, contacts: nil, contacts_json: {}, message_id: nil, recipient: nil
254
273
  )
255
274
  params = {
256
275
  messaging_product: "whatsapp",
@@ -260,6 +279,7 @@ module WhatsappSdk
260
279
  }
261
280
  params[:contacts] = contacts ? contacts.map(&:to_h) : contacts_json
262
281
  params[:context] = { message_id: message_id } if message_id
282
+ apply_recipient!(params, recipient_number, recipient)
263
283
 
264
284
  response = send_request(
265
285
  endpoint: endpoint(sender_id),
@@ -284,9 +304,10 @@ module WhatsappSdk
284
304
  # @param interactive_json [Json] The interactive object as a Json.
285
305
  # If you pass interactive_json, you can't pass interactive.
286
306
  # @param message_id [String] The id of the message to reply to.
307
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
287
308
  # @return [MessageDataResponse] Response object.
288
309
  def send_interactive_message(
289
- sender_id:, recipient_number:, interactive: nil, interactive_json: nil, message_id: nil
310
+ sender_id:, recipient_number: nil, interactive: nil, interactive_json: nil, message_id: nil, recipient: nil
290
311
  )
291
312
  if !interactive && !interactive_json
292
313
  raise Resource::Errors::MissingArgumentError,
@@ -306,6 +327,7 @@ module WhatsappSdk
306
327
  interactive.to_json
307
328
  end
308
329
  params[:context] = { message_id: message_id } if message_id
330
+ apply_recipient!(params, recipient_number, recipient)
309
331
 
310
332
  response = send_request(
311
333
  endpoint: endpoint(sender_id),
@@ -348,9 +370,11 @@ module WhatsappSdk
348
370
  # @param language [String] template language.
349
371
  # @param components [Component] Component.
350
372
  # @param components_json [Json] The component as a Json. If you pass components_json, you can't pass components.
373
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
374
+ # Not allowed for authentication templates.
351
375
  # @return [MessageDataResponse] Response object.
352
376
  def send_template(
353
- sender_id:, recipient_number:, name:, language:, components: nil, components_json: nil
377
+ sender_id:, name:, language:, recipient_number: nil, components: nil, components_json: nil, recipient: nil
354
378
  )
355
379
  if !components && !components_json
356
380
  raise Resource::Errors::MissingArgumentError,
@@ -373,6 +397,7 @@ module WhatsappSdk
373
397
  else
374
398
  components.map(&:to_json)
375
399
  end
400
+ apply_recipient!(params, recipient_number, recipient)
376
401
 
377
402
  response = send_request(
378
403
  endpoint: endpoint(sender_id),
@@ -389,8 +414,9 @@ module WhatsappSdk
389
414
  # @param recipient_number [Integer] Recipient' Phone number.
390
415
  # @param message_id [String] the id of the message to reaction.
391
416
  # @param emoji [String] unicode of the emoji to send.
417
+ # @param recipient [String] Recipient' Business-Scoped User ID (BSUID). Optional alternative to recipient_number.
392
418
  # @return [MessageDataResponse] Response object.
393
- def send_reaction(sender_id:, recipient_number:, message_id:, emoji:)
419
+ def send_reaction(sender_id:, message_id:, emoji:, recipient_number: nil, recipient: nil)
394
420
  params = {
395
421
  messaging_product: "whatsapp",
396
422
  recipient_type: "individual",
@@ -401,6 +427,7 @@ module WhatsappSdk
401
427
  emoji: emoji
402
428
  }
403
429
  }
430
+ apply_recipient!(params, recipient_number, recipient)
404
431
 
405
432
  response = send_request(
406
433
  endpoint: endpoint(sender_id),
@@ -441,6 +468,22 @@ module WhatsappSdk
441
468
  def endpoint(sender_id)
442
469
  "#{sender_id}/messages"
443
470
  end
471
+
472
+ # Applies the destination to the payload.
473
+ # - Phone send (recipient_number present): keeps `to`, never adds `recipient` — payload is
474
+ # identical to the pre-BSUID behavior.
475
+ # - BSUID send (recipient_number absent, recipient present): drops `to` and sets `recipient`
476
+ # (the field Meta uses to address a Business-Scoped User ID).
477
+ # Requires at least one destination.
478
+ def apply_recipient!(params, recipient_number, recipient)
479
+ if (recipient_number.nil? ? recipient : recipient_number).to_s.strip.empty?
480
+ raise Resource::Errors::MissingArgumentError, "recipient_number or recipient is required"
481
+ end
482
+
483
+ params.delete(:to) if recipient_number.nil?
484
+ params[:recipient] = recipient if recipient_number.nil? && recipient
485
+ params
486
+ end
444
487
  end
445
488
  end
446
489
  end
@@ -16,10 +16,14 @@ module WhatsappSdk
16
16
  end
17
17
 
18
18
  def self.response_error?(response:)
19
+ return false unless response.is_a?(Hash)
20
+
19
21
  response["error"]
20
22
  end
21
23
 
22
24
  def self.build_from_response(response:)
25
+ return unless response.is_a?(Hash)
26
+
23
27
  error_response = response["error"]
24
28
  return unless error_response
25
29
 
@@ -27,7 +27,9 @@ module WhatsappSdk
27
27
  end
28
28
 
29
29
  def parse_contact(contact_json)
30
- Resource::ContactResponse.new(input: contact_json["input"], wa_id: contact_json["wa_id"])
30
+ Resource::ContactResponse.new(
31
+ input: contact_json["input"], wa_id: contact_json["wa_id"], user_id: contact_json["user_id"]
32
+ )
31
33
  end
32
34
  end
33
35
  end
@@ -175,6 +175,50 @@ module WhatsappSdk
175
175
  Api::Responses::SuccessResponse.success_response?(response: response)
176
176
  end
177
177
 
178
+ # Get Template Analytics
179
+ #
180
+ # Get analytics data for message templates over a specified time range.
181
+ # @param business_id [Integer] The business ID.
182
+ # @param start_timestamp [Integer] The start of the time range to retrieve analytics data, in Unix timestamp.
183
+ # @param end_timestamp [Integer] The end of the time range to retrieve analytics data, in Unix timestamp.
184
+ # @param template_ids [Array<String>] An array of template IDs for which to retrieve analytics data.
185
+ # @param metric_types [Array<String>] An array of metric types to retrieve.
186
+ def template_analytics(
187
+ business_id:, start_timestamp:, end_timestamp:, template_ids:, metric_types: [],
188
+ granularity: WhatsappSdk::Resource::TemplateAnalytic::Granularity::DAILY,
189
+ after: nil
190
+ )
191
+ if !metric_types.empty? && !valid_metric_types?(metric_types)
192
+ valid_types = WhatsappSdk::Resource::TemplateAnalytic::MetricType::METRIC_TYPES.join(', ')
193
+
194
+ raise ArgumentError, "Invalid metric type. Valid types are: #{valid_types}."
195
+ end
196
+
197
+ if granularity != WhatsappSdk::Resource::TemplateAnalytic::Granularity::DAILY
198
+ raise ArgumentError, "Invalid granularity. The only supported granularity is DAILY."
199
+ end
200
+
201
+ query_params = {
202
+ start: start_timestamp,
203
+ end: end_timestamp,
204
+ template_ids: template_ids.join(","),
205
+ metric_types: metric_types.join(","),
206
+ granularity: granularity
207
+ }
208
+ query_params[:after] = after if after
209
+
210
+ response = send_request(
211
+ endpoint: "#{business_id}/template_analytics?#{URI.encode_www_form(query_params)}",
212
+ http_method: "get"
213
+ )
214
+
215
+ Api::Responses::PaginationRecords.new(
216
+ records: parse_template_analytics(response['data']),
217
+ before: response.dig('paging', 'cursors', 'before'),
218
+ after: response.dig('paging', 'cursors', 'after')
219
+ )
220
+ end
221
+
178
222
  private
179
223
 
180
224
  def parse_templates(templates_data)
@@ -182,6 +226,18 @@ module WhatsappSdk
182
226
  Resource::Template.from_hash(template)
183
227
  end
184
228
  end
229
+
230
+ def parse_template_analytics(analytics_data)
231
+ analytics_data.map do |analytic|
232
+ Resource::TemplateAnalytic.from_hash(analytic)
233
+ end
234
+ end
235
+
236
+ def valid_metric_types?(metric_types)
237
+ metric_types.all? do |type|
238
+ WhatsappSdk::Resource::TemplateAnalytic::MetricType.valid?(type)
239
+ end
240
+ end
185
241
  end
186
242
  end
187
243
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsappSdk
4
+ module Resource
5
+ class BusinessAccount
6
+ attr_accessor :id, :name, :timezone_id, :message_template_namespace, :account_review_status,
7
+ :business_verification_status, :country, :ownership_type, :primary_business_location,
8
+ :analytics, :conversation_analytics, :pricing_analytics, :call_analytics
9
+
10
+ def self.from_hash(hash)
11
+ business_account = BusinessAccount.new
12
+ business_account.id = hash["id"]
13
+ business_account.name = hash["name"]
14
+ business_account.timezone_id = hash["timezone_id"]
15
+ business_account.message_template_namespace = hash["message_template_namespace"]
16
+ business_account.account_review_status = hash["account_review_status"]
17
+ business_account.business_verification_status = hash["business_verification_status"]
18
+ business_account.country = hash["country"]
19
+ business_account.ownership_type = hash["ownership_type"]
20
+ business_account.primary_business_location = hash["primary_business_location"]
21
+ business_account.analytics = hash["analytics"]
22
+ business_account.conversation_analytics = hash["conversation_analytics"]
23
+ business_account.pricing_analytics = hash["pricing_analytics"]
24
+ business_account.call_analytics = hash["call_analytics"]
25
+
26
+ business_account
27
+ end
28
+
29
+ def ==(other)
30
+ return false unless other.is_a?(WhatsappSdk::Resource::BusinessAccount)
31
+
32
+ %i[id name timezone_id message_template_namespace account_review_status
33
+ business_verification_status country ownership_type primary_business_location].all? do |field|
34
+ send(field) == other.send(field)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,11 +3,12 @@
3
3
  module WhatsappSdk
4
4
  module Resource
5
5
  class ContactResponse
6
- attr_accessor :wa_id, :input
6
+ attr_accessor :wa_id, :input, :user_id
7
7
 
8
- def initialize(input:, wa_id:)
8
+ def initialize(input:, wa_id: nil, user_id: nil)
9
9
  @input = input
10
10
  @wa_id = wa_id
11
+ @user_id = user_id
11
12
  end
12
13
  end
13
14
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsappSdk
4
+ module Resource
5
+ class TemplateAnalytic
6
+ module Granularity
7
+ DAILY = "DAILY"
8
+ end
9
+
10
+ module MetricType
11
+ COST = "COST"
12
+ CLICKED = "CLICKED"
13
+ DELIVERED = "DELIVERED"
14
+ READ = "READ"
15
+ SENT = "SENT"
16
+
17
+ # (MM API for WhatsApp only)
18
+ APP_ACTIVATIONS = "APP_ACTIVATIONS"
19
+ APP_ADD_TO_CART = "APP_ADD_TO_CART"
20
+ APP_CHECKOUTS_INITIATED = "APP_CHECKOUTS_INITIATED"
21
+ APP_PURCHASES = "APP_PURCHASES"
22
+ APP_PURCHASES_CONVERSION_VALUE = "APP_PURCHASES_CONVERSION_VALUE"
23
+ WEBSITE_ADD_TO_CART = "WEBSITE_ADD_TO_CART"
24
+ WEBSITE_CHECKOUTS_INITIATED = "WEBSITE_CHECKOUTS_INITIATED"
25
+ WEBSITE_PURCHASES = "WEBSITE_PURCHASES"
26
+ WEBSITE_PURCHASES_CONVERSION_VALUE = "WEBSITE_PURCHASES_CONVERSION_VALUE"
27
+
28
+ METRIC_TYPES = [COST, CLICKED, DELIVERED, READ, SENT, APP_ACTIVATIONS, APP_ADD_TO_CART, APP_CHECKOUTS_INITIATED,
29
+ APP_PURCHASES, APP_PURCHASES_CONVERSION_VALUE, WEBSITE_ADD_TO_CART, WEBSITE_CHECKOUTS_INITIATED,
30
+ WEBSITE_PURCHASES, WEBSITE_PURCHASES_CONVERSION_VALUE].freeze
31
+
32
+ def self.valid?(metric_type)
33
+ METRIC_TYPES.include?(metric_type)
34
+ end
35
+ end
36
+
37
+ attr_accessor :granularity, :product_type, :data_points
38
+
39
+ def initialize(granularity:, product_type:, data_points:)
40
+ @granularity = granularity
41
+ @product_type = product_type
42
+ @data_points = data_points
43
+ end
44
+
45
+ def self.from_hash(hash)
46
+ new(
47
+ granularity: hash["granularity"],
48
+ product_type: hash["product_type"],
49
+ data_points: hash["data_points"]
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WhatsappSdk
4
- VERSION = "1.0.5"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatsapp_sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ignacio-chiazzo
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-01-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -134,6 +133,7 @@ files:
134
133
  - example.rb
135
134
  - lib/whatsapp_sdk.rb
136
135
  - lib/whatsapp_sdk/api/api_configuration.rb
136
+ - lib/whatsapp_sdk/api/business_account.rb
137
137
  - lib/whatsapp_sdk/api/business_profile.rb
138
138
  - lib/whatsapp_sdk/api/client.rb
139
139
  - lib/whatsapp_sdk/api/medias.rb
@@ -151,6 +151,7 @@ files:
151
151
  - lib/whatsapp_sdk/error.rb
152
152
  - lib/whatsapp_sdk/resource/address.rb
153
153
  - lib/whatsapp_sdk/resource/address_type.rb
154
+ - lib/whatsapp_sdk/resource/business_account.rb
154
155
  - lib/whatsapp_sdk/resource/business_profile.rb
155
156
  - lib/whatsapp_sdk/resource/button_parameter.rb
156
157
  - lib/whatsapp_sdk/resource/component.rb
@@ -181,6 +182,7 @@ files:
181
182
  - lib/whatsapp_sdk/resource/phone_number.rb
182
183
  - lib/whatsapp_sdk/resource/phone_number_component.rb
183
184
  - lib/whatsapp_sdk/resource/template.rb
185
+ - lib/whatsapp_sdk/resource/template_analytic.rb
184
186
  - lib/whatsapp_sdk/resource/url.rb
185
187
  - lib/whatsapp_sdk/version.rb
186
188
  - tmp/whatsapp.png
@@ -193,7 +195,6 @@ metadata:
193
195
  source_code_uri: https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk
194
196
  changelog_uri: https://github.com/ignacio-chiazzo/ruby_whatsapp_sdk/blob/main/CHANGELOG.md
195
197
  rubygems_mfa_required: 'true'
196
- post_install_message:
197
198
  rdoc_options: []
198
199
  require_paths:
199
200
  - lib
@@ -208,8 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
208
209
  - !ruby/object:Gem::Version
209
210
  version: '0'
210
211
  requirements: []
211
- rubygems_version: 3.4.1
212
- signing_key:
212
+ rubygems_version: 4.0.20
213
213
  specification_version: 4
214
214
  summary: Use the Ruby Whatsapp SDK to communicate with Whatsapp API using the Cloud
215
215
  API