whatsapp-cloud-api-ruby 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +82 -0
- data/CHANGELOG.md +92 -0
- data/Gemfile +21 -0
- data/README.md +735 -0
- data/Rakefile +41 -0
- data/TEMPLATE_TOOLS_GUIDE.md +121 -0
- data/WHATSAPP_24_HOUR_GUIDE.md +134 -0
- data/examples/advanced_features.rb +350 -0
- data/examples/basic_messaging.rb +137 -0
- data/examples/media_management.rb +254 -0
- data/examples/template_management.rb +391 -0
- data/lib/whatsapp_cloud_api/client.rb +317 -0
- data/lib/whatsapp_cloud_api/errors.rb +330 -0
- data/lib/whatsapp_cloud_api/resources/calls.rb +173 -0
- data/lib/whatsapp_cloud_api/resources/contacts.rb +191 -0
- data/lib/whatsapp_cloud_api/resources/conversations.rb +104 -0
- data/lib/whatsapp_cloud_api/resources/media.rb +206 -0
- data/lib/whatsapp_cloud_api/resources/messages.rb +381 -0
- data/lib/whatsapp_cloud_api/resources/phone_numbers.rb +86 -0
- data/lib/whatsapp_cloud_api/resources/templates.rb +284 -0
- data/lib/whatsapp_cloud_api/types.rb +263 -0
- data/lib/whatsapp_cloud_api/version.rb +5 -0
- data/lib/whatsapp_cloud_api.rb +69 -0
- data/scripts/.env.example +18 -0
- data/scripts/kapso_template_finder.rb +91 -0
- data/scripts/sdk_setup.rb +405 -0
- data/scripts/test.rb +60 -0
- metadata +254 -0
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Resources
|
5
|
+
class PhoneNumbers
|
6
|
+
def initialize(client)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
# Request verification code
|
11
|
+
def request_code(phone_number_id:, code_method:, language: 'en_US')
|
12
|
+
validate_code_method(code_method)
|
13
|
+
|
14
|
+
payload = {
|
15
|
+
code_method: code_method.upcase,
|
16
|
+
language: language
|
17
|
+
}
|
18
|
+
|
19
|
+
response = @client.request(:post, "#{phone_number_id}/request_code",
|
20
|
+
body: payload.to_json, response_type: :json)
|
21
|
+
Types::GraphSuccessResponse.new(response)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Verify the received code
|
25
|
+
def verify_code(phone_number_id:, code:)
|
26
|
+
raise ArgumentError, 'Verification code cannot be empty' if code.nil? || code.strip.empty?
|
27
|
+
|
28
|
+
payload = { code: code.to_s }
|
29
|
+
|
30
|
+
response = @client.request(:post, "#{phone_number_id}/verify_code",
|
31
|
+
body: payload.to_json, response_type: :json)
|
32
|
+
Types::GraphSuccessResponse.new(response)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Register phone number
|
36
|
+
def register(phone_number_id:, pin:, data_localization_region: nil)
|
37
|
+
raise ArgumentError, 'PIN cannot be empty' if pin.nil? || pin.strip.empty?
|
38
|
+
|
39
|
+
payload = { pin: pin.to_s }
|
40
|
+
payload[:data_localization_region] = data_localization_region if data_localization_region
|
41
|
+
|
42
|
+
response = @client.request(:post, "#{phone_number_id}/register",
|
43
|
+
body: payload.to_json, response_type: :json)
|
44
|
+
Types::GraphSuccessResponse.new(response)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Deregister phone number
|
48
|
+
def deregister(phone_number_id:)
|
49
|
+
response = @client.request(:post, "#{phone_number_id}/deregister",
|
50
|
+
body: {}.to_json, response_type: :json)
|
51
|
+
Types::GraphSuccessResponse.new(response)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Update phone number settings
|
55
|
+
def update_settings(phone_number_id:, messaging_product: 'whatsapp',
|
56
|
+
webhooks: nil, application: nil)
|
57
|
+
payload = { messaging_product: messaging_product }
|
58
|
+
payload[:webhooks] = webhooks if webhooks
|
59
|
+
payload[:application] = application if application
|
60
|
+
|
61
|
+
response = @client.request(:post, phone_number_id,
|
62
|
+
body: payload.to_json, response_type: :json)
|
63
|
+
Types::GraphSuccessResponse.new(response)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get phone number info
|
67
|
+
def get(phone_number_id:, fields: nil)
|
68
|
+
query_params = {}
|
69
|
+
query_params[:fields] = fields if fields
|
70
|
+
|
71
|
+
response = @client.request(:get, phone_number_id,
|
72
|
+
query: query_params, response_type: :json)
|
73
|
+
response
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def validate_code_method(method)
|
79
|
+
valid_methods = %w[SMS VOICE]
|
80
|
+
unless valid_methods.include?(method.to_s.upcase)
|
81
|
+
raise ArgumentError, "Invalid code method '#{method}'. Must be one of: #{valid_methods.join(', ')}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Resources
|
5
|
+
class Templates
|
6
|
+
def initialize(client)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
# List templates for a business account
|
11
|
+
def list(business_account_id:, limit: nil, after: nil, before: nil,
|
12
|
+
name: nil, status: nil, category: nil, language: nil,
|
13
|
+
name_or_content: nil, quality_score: nil)
|
14
|
+
query_params = {
|
15
|
+
limit: limit,
|
16
|
+
after: after,
|
17
|
+
before: before,
|
18
|
+
name: name,
|
19
|
+
status: status,
|
20
|
+
category: category,
|
21
|
+
language: language,
|
22
|
+
name_or_content: name_or_content,
|
23
|
+
quality_score: quality_score
|
24
|
+
}.compact
|
25
|
+
|
26
|
+
response = @client.request(:get, "#{business_account_id}/message_templates",
|
27
|
+
query: query_params, response_type: :json)
|
28
|
+
Types::PagedResponse.new(response, Types::MessageTemplate)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get a specific template
|
32
|
+
def get(business_account_id:, template_id:, fields: nil)
|
33
|
+
query_params = {}
|
34
|
+
query_params[:fields] = fields if fields
|
35
|
+
|
36
|
+
response = @client.request(:get, "#{business_account_id}/message_templates/#{template_id}",
|
37
|
+
query: query_params, response_type: :json)
|
38
|
+
Types::MessageTemplate.new(response)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Create a new template
|
42
|
+
def create(business_account_id:, name:, language:, category:, components:,
|
43
|
+
allow_category_change: nil, message_send_ttl_seconds: nil)
|
44
|
+
validate_template_data(name: name, language: language, category: category, components: components)
|
45
|
+
|
46
|
+
payload = {
|
47
|
+
name: name,
|
48
|
+
language: language,
|
49
|
+
category: category,
|
50
|
+
components: normalize_components(components)
|
51
|
+
}
|
52
|
+
|
53
|
+
payload[:allow_category_change] = allow_category_change unless allow_category_change.nil?
|
54
|
+
payload[:message_send_ttl_seconds] = message_send_ttl_seconds if message_send_ttl_seconds
|
55
|
+
|
56
|
+
response = @client.request(:post, "#{business_account_id}/message_templates",
|
57
|
+
body: payload.to_json, response_type: :json)
|
58
|
+
Types::TemplateCreateResponse.new(response)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Update a template
|
62
|
+
def update(business_account_id:, template_id:, category: nil, components: nil)
|
63
|
+
payload = {}
|
64
|
+
payload[:category] = category if category
|
65
|
+
payload[:components] = normalize_components(components) if components
|
66
|
+
|
67
|
+
return if payload.empty?
|
68
|
+
|
69
|
+
response = @client.request(:post, "#{business_account_id}/message_templates/#{template_id}",
|
70
|
+
body: payload.to_json, response_type: :json)
|
71
|
+
Types::GraphSuccessResponse.new(response)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Delete a template
|
75
|
+
def delete(business_account_id:, name: nil, template_id: nil, hsm_id: nil, language: nil)
|
76
|
+
if template_id
|
77
|
+
# Delete by template ID
|
78
|
+
response = @client.request(:delete, "#{business_account_id}/message_templates/#{template_id}",
|
79
|
+
response_type: :json)
|
80
|
+
elsif name
|
81
|
+
# Delete by name and language
|
82
|
+
query_params = { name: name }
|
83
|
+
query_params[:language] = language if language
|
84
|
+
query_params[:hsm_id] = hsm_id if hsm_id
|
85
|
+
|
86
|
+
response = @client.request(:delete, "#{business_account_id}/message_templates",
|
87
|
+
query: query_params, response_type: :json)
|
88
|
+
else
|
89
|
+
raise ArgumentError, 'Must provide either template_id or name'
|
90
|
+
end
|
91
|
+
|
92
|
+
Types::GraphSuccessResponse.new(response)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Template builder helpers
|
96
|
+
def build_text_component(text:, example: nil)
|
97
|
+
component = { type: 'BODY', text: text }
|
98
|
+
component[:example] = example if example
|
99
|
+
component
|
100
|
+
end
|
101
|
+
|
102
|
+
def build_header_component(type:, text: nil, image: nil, video: nil,
|
103
|
+
document: nil, example: nil)
|
104
|
+
component = { type: 'HEADER', format: type.upcase }
|
105
|
+
|
106
|
+
case type.upcase
|
107
|
+
when 'TEXT'
|
108
|
+
component[:text] = text if text
|
109
|
+
when 'IMAGE'
|
110
|
+
component[:example] = { header_handle: [image] } if image
|
111
|
+
when 'VIDEO'
|
112
|
+
component[:example] = { header_handle: [video] } if video
|
113
|
+
when 'DOCUMENT'
|
114
|
+
component[:example] = { header_handle: [document] } if document
|
115
|
+
end
|
116
|
+
|
117
|
+
component[:example] = example if example
|
118
|
+
component
|
119
|
+
end
|
120
|
+
|
121
|
+
def build_footer_component(text: nil, code_expiration_minutes: nil)
|
122
|
+
component = { type: 'FOOTER' }
|
123
|
+
component[:text] = text if text
|
124
|
+
component[:code_expiration_minutes] = code_expiration_minutes if code_expiration_minutes
|
125
|
+
component
|
126
|
+
end
|
127
|
+
|
128
|
+
def build_buttons_component(buttons:)
|
129
|
+
{
|
130
|
+
type: 'BUTTONS',
|
131
|
+
buttons: buttons.map { |btn| normalize_button(btn) }
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
def build_button(type:, text: nil, url: nil, phone_number: nil,
|
136
|
+
otp_type: nil, autofill_text: nil, package_name: nil,
|
137
|
+
signature_hash: nil)
|
138
|
+
button = { type: type.upcase }
|
139
|
+
|
140
|
+
case type.upcase
|
141
|
+
when 'QUICK_REPLY'
|
142
|
+
button[:text] = text if text
|
143
|
+
when 'URL'
|
144
|
+
button[:text] = text if text
|
145
|
+
button[:url] = url if url
|
146
|
+
when 'PHONE_NUMBER'
|
147
|
+
button[:text] = text if text
|
148
|
+
button[:phone_number] = phone_number if phone_number
|
149
|
+
when 'OTP'
|
150
|
+
button[:otp_type] = otp_type if otp_type
|
151
|
+
button[:text] = text if text
|
152
|
+
button[:autofill_text] = autofill_text if autofill_text
|
153
|
+
button[:package_name] = package_name if package_name
|
154
|
+
button[:signature_hash] = signature_hash if signature_hash
|
155
|
+
end
|
156
|
+
|
157
|
+
button
|
158
|
+
end
|
159
|
+
|
160
|
+
# Authentication template builder
|
161
|
+
def build_authentication_template(name:, language:, ttl_seconds: 60,
|
162
|
+
add_security_recommendation: true,
|
163
|
+
code_expiration_minutes: 10,
|
164
|
+
otp_type: 'COPY_CODE')
|
165
|
+
components = []
|
166
|
+
|
167
|
+
# Body component with security recommendation
|
168
|
+
body_component = { type: 'BODY' }
|
169
|
+
body_component[:add_security_recommendation] = add_security_recommendation
|
170
|
+
components << body_component
|
171
|
+
|
172
|
+
# Footer component with expiration
|
173
|
+
if code_expiration_minutes
|
174
|
+
components << build_footer_component(code_expiration_minutes: code_expiration_minutes)
|
175
|
+
end
|
176
|
+
|
177
|
+
# OTP button
|
178
|
+
components << build_buttons_component(
|
179
|
+
buttons: [build_button(type: 'OTP', otp_type: otp_type)]
|
180
|
+
)
|
181
|
+
|
182
|
+
{
|
183
|
+
name: name,
|
184
|
+
language: language,
|
185
|
+
category: 'AUTHENTICATION',
|
186
|
+
message_send_ttl_seconds: ttl_seconds,
|
187
|
+
components: components
|
188
|
+
}
|
189
|
+
end
|
190
|
+
|
191
|
+
# Marketing template builder
|
192
|
+
def build_marketing_template(name:, language:, header: nil, body:, footer: nil,
|
193
|
+
buttons: nil, body_example: nil)
|
194
|
+
components = []
|
195
|
+
|
196
|
+
# Header component
|
197
|
+
components << header if header
|
198
|
+
|
199
|
+
# Body component
|
200
|
+
body_component = build_text_component(text: body, example: body_example)
|
201
|
+
components << body_component
|
202
|
+
|
203
|
+
# Footer component
|
204
|
+
components << build_footer_component(text: footer) if footer
|
205
|
+
|
206
|
+
# Buttons component
|
207
|
+
components << build_buttons_component(buttons: buttons) if buttons
|
208
|
+
|
209
|
+
{
|
210
|
+
name: name,
|
211
|
+
language: language,
|
212
|
+
category: 'MARKETING',
|
213
|
+
components: components
|
214
|
+
}
|
215
|
+
end
|
216
|
+
|
217
|
+
# Utility template builder
|
218
|
+
def build_utility_template(name:, language:, body:, header: nil, footer: nil,
|
219
|
+
buttons: nil, body_example: nil)
|
220
|
+
components = []
|
221
|
+
|
222
|
+
# Header component
|
223
|
+
components << header if header
|
224
|
+
|
225
|
+
# Body component
|
226
|
+
body_component = build_text_component(text: body, example: body_example)
|
227
|
+
components << body_component
|
228
|
+
|
229
|
+
# Footer component
|
230
|
+
components << build_footer_component(text: footer) if footer
|
231
|
+
|
232
|
+
# Buttons component
|
233
|
+
components << build_buttons_component(buttons: buttons) if buttons
|
234
|
+
|
235
|
+
{
|
236
|
+
name: name,
|
237
|
+
language: language,
|
238
|
+
category: 'UTILITY',
|
239
|
+
components: components
|
240
|
+
}
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
|
245
|
+
def validate_template_data(name:, language:, category:, components:)
|
246
|
+
raise ArgumentError, 'Template name cannot be empty' if name.nil? || name.strip.empty?
|
247
|
+
raise ArgumentError, 'Language cannot be empty' if language.nil? || language.strip.empty?
|
248
|
+
raise ArgumentError, 'Category cannot be empty' if category.nil? || category.strip.empty?
|
249
|
+
raise ArgumentError, 'Components cannot be empty' if components.nil? || components.empty?
|
250
|
+
|
251
|
+
# Validate category
|
252
|
+
valid_categories = Types::TEMPLATE_CATEGORIES
|
253
|
+
unless valid_categories.include?(category.upcase)
|
254
|
+
raise ArgumentError, "Invalid category '#{category}'. Must be one of: #{valid_categories.join(', ')}"
|
255
|
+
end
|
256
|
+
|
257
|
+
# Validate components structure
|
258
|
+
components.each_with_index do |component, index|
|
259
|
+
unless component.is_a?(Hash) && component[:type]
|
260
|
+
raise ArgumentError, "Component at index #{index} must be a Hash with :type key"
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
def normalize_components(components)
|
266
|
+
components.map { |component| normalize_component(component) }
|
267
|
+
end
|
268
|
+
|
269
|
+
def normalize_component(component)
|
270
|
+
# Ensure component keys are strings for API compatibility
|
271
|
+
normalized = {}
|
272
|
+
component.each { |key, value| normalized[key.to_s] = value }
|
273
|
+
normalized
|
274
|
+
end
|
275
|
+
|
276
|
+
def normalize_button(button)
|
277
|
+
# Ensure button keys are strings for API compatibility
|
278
|
+
normalized = {}
|
279
|
+
button.each { |key, value| normalized[key.to_s] = value }
|
280
|
+
normalized
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Types
|
5
|
+
# Message status types
|
6
|
+
MESSAGE_STATUSES = %w[accepted held_for_quality_assessment].freeze
|
7
|
+
|
8
|
+
# Template status types
|
9
|
+
TEMPLATE_STATUSES = %w[APPROVED PENDING REJECTED PAUSED IN_APPEAL DISABLED].freeze
|
10
|
+
|
11
|
+
# Template categories
|
12
|
+
TEMPLATE_CATEGORIES = %w[MARKETING UTILITY AUTHENTICATION UNKNOWN].freeze
|
13
|
+
|
14
|
+
# Media types
|
15
|
+
MEDIA_TYPES = %w[image audio video document sticker].freeze
|
16
|
+
|
17
|
+
# Interactive message types
|
18
|
+
INTERACTIVE_TYPES = %w[button list product product_list flow address location_request call_permission].freeze
|
19
|
+
|
20
|
+
# Message response structure
|
21
|
+
class SendMessageResponse
|
22
|
+
attr_reader :messaging_product, :contacts, :messages
|
23
|
+
|
24
|
+
def initialize(data)
|
25
|
+
@messaging_product = data['messaging_product']
|
26
|
+
@contacts = data['contacts']&.map { |c| MessageContact.new(c) } || []
|
27
|
+
@messages = data['messages']&.map { |m| MessageInfo.new(m) } || []
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class MessageContact
|
32
|
+
attr_reader :input, :wa_id
|
33
|
+
|
34
|
+
def initialize(data)
|
35
|
+
@input = data['input']
|
36
|
+
@wa_id = data['wa_id']
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
class MessageInfo
|
41
|
+
attr_reader :id, :message_status
|
42
|
+
|
43
|
+
def initialize(data)
|
44
|
+
@id = data['id']
|
45
|
+
@message_status = data['message_status']
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Media response structures
|
50
|
+
class MediaUploadResponse
|
51
|
+
attr_reader :id
|
52
|
+
|
53
|
+
def initialize(data)
|
54
|
+
@id = data['id']
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
class MediaMetadataResponse
|
59
|
+
attr_reader :messaging_product, :url, :mime_type, :sha256, :file_size, :id
|
60
|
+
|
61
|
+
def initialize(data)
|
62
|
+
@messaging_product = data['messaging_product']
|
63
|
+
@url = data['url']
|
64
|
+
@mime_type = data['mime_type']
|
65
|
+
@sha256 = data['sha256']
|
66
|
+
@file_size = data['file_size']
|
67
|
+
@id = data['id']
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Template structures
|
72
|
+
class MessageTemplate
|
73
|
+
attr_reader :id, :name, :category, :language, :status, :components,
|
74
|
+
:quality_score_category, :warnings, :previous_category,
|
75
|
+
:library_template_name, :last_updated_time
|
76
|
+
|
77
|
+
def initialize(data)
|
78
|
+
@id = data['id']
|
79
|
+
@name = data['name']
|
80
|
+
@category = data['category']
|
81
|
+
@language = data['language']
|
82
|
+
@status = data['status']
|
83
|
+
@components = data['components']
|
84
|
+
@quality_score_category = data['quality_score_category']
|
85
|
+
@warnings = data['warnings']
|
86
|
+
@previous_category = data['previous_category']
|
87
|
+
@library_template_name = data['library_template_name']
|
88
|
+
@last_updated_time = data['last_updated_time']
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class TemplateCreateResponse
|
93
|
+
attr_reader :id, :status, :category
|
94
|
+
|
95
|
+
def initialize(data)
|
96
|
+
@id = data['id']
|
97
|
+
@status = data['status']
|
98
|
+
@category = data['category']
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Paging structures
|
103
|
+
class GraphPaging
|
104
|
+
attr_reader :cursors, :next_page, :previous_page
|
105
|
+
|
106
|
+
def initialize(data)
|
107
|
+
@cursors = data['cursors'] || {}
|
108
|
+
@next_page = data['next']
|
109
|
+
@previous_page = data['previous']
|
110
|
+
end
|
111
|
+
|
112
|
+
def before
|
113
|
+
cursors['before']
|
114
|
+
end
|
115
|
+
|
116
|
+
def after
|
117
|
+
cursors['after']
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
class PagedResponse
|
122
|
+
attr_reader :data, :paging
|
123
|
+
|
124
|
+
def initialize(data, item_class = nil)
|
125
|
+
@data = if item_class && data['data'].is_a?(Array)
|
126
|
+
data['data'].map { |item| item_class.new(item) }
|
127
|
+
else
|
128
|
+
data['data'] || []
|
129
|
+
end
|
130
|
+
@paging = GraphPaging.new(data['paging'] || {})
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Success response
|
135
|
+
class GraphSuccessResponse
|
136
|
+
attr_reader :success
|
137
|
+
|
138
|
+
def initialize(data = nil)
|
139
|
+
@success = data.nil? || data['success'] || true
|
140
|
+
end
|
141
|
+
|
142
|
+
def success?
|
143
|
+
@success
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Business profile structures
|
148
|
+
class BusinessProfileEntry
|
149
|
+
attr_reader :about, :address, :description, :email, :websites,
|
150
|
+
:vertical, :profile_picture_url, :profile_picture_handle
|
151
|
+
|
152
|
+
def initialize(data)
|
153
|
+
@about = data['about']
|
154
|
+
@address = data['address']
|
155
|
+
@description = data['description']
|
156
|
+
@email = data['email']
|
157
|
+
@websites = data['websites']
|
158
|
+
@vertical = data['vertical']
|
159
|
+
@profile_picture_url = data['profile_picture_url']
|
160
|
+
@profile_picture_handle = data['profile_picture_handle']
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
# Conversation structures
|
165
|
+
class ConversationRecord
|
166
|
+
attr_reader :id, :phone_number, :phone_number_id, :status, :last_active_at,
|
167
|
+
:kapso, :metadata
|
168
|
+
|
169
|
+
def initialize(data)
|
170
|
+
@id = data['id']
|
171
|
+
@phone_number = data['phone_number']
|
172
|
+
@phone_number_id = data['phone_number_id']
|
173
|
+
@status = data['status']
|
174
|
+
@last_active_at = data['last_active_at']
|
175
|
+
@kapso = data['kapso']
|
176
|
+
@metadata = data['metadata']
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Contact structures
|
181
|
+
class ContactRecord
|
182
|
+
attr_reader :wa_id, :phone_number, :profile_name, :metadata
|
183
|
+
|
184
|
+
def initialize(data)
|
185
|
+
@wa_id = data['wa_id']
|
186
|
+
@phone_number = data['phone_number']
|
187
|
+
@profile_name = data['profile_name']
|
188
|
+
@metadata = data['metadata']
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Call structures
|
193
|
+
class CallRecord
|
194
|
+
attr_reader :id, :direction, :status, :duration_seconds, :started_at,
|
195
|
+
:ended_at, :whatsapp_conversation_id, :whatsapp_contact_id
|
196
|
+
|
197
|
+
def initialize(data)
|
198
|
+
@id = data['id']
|
199
|
+
@direction = data['direction']
|
200
|
+
@status = data['status']
|
201
|
+
@duration_seconds = data['duration_seconds']
|
202
|
+
@started_at = data['started_at']
|
203
|
+
@ended_at = data['ended_at']
|
204
|
+
@whatsapp_conversation_id = data['whatsapp_conversation_id']
|
205
|
+
@whatsapp_contact_id = data['whatsapp_contact_id']
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
class CallConnectResponse
|
210
|
+
attr_reader :messaging_product, :calls
|
211
|
+
|
212
|
+
def initialize(data)
|
213
|
+
@messaging_product = data['messaging_product']
|
214
|
+
@calls = data['calls'] || []
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
class CallActionResponse < GraphSuccessResponse
|
219
|
+
attr_reader :messaging_product
|
220
|
+
|
221
|
+
def initialize(data)
|
222
|
+
super(data)
|
223
|
+
@messaging_product = data['messaging_product']
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Utility method to convert snake_case to camelCase for API requests
|
228
|
+
def self.to_camel_case(str)
|
229
|
+
str.split('_').map.with_index { |word, i| i == 0 ? word : word.capitalize }.join
|
230
|
+
end
|
231
|
+
|
232
|
+
# Utility method to convert camelCase to snake_case for Ruby conventions
|
233
|
+
def self.to_snake_case(str)
|
234
|
+
str.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
|
235
|
+
end
|
236
|
+
|
237
|
+
# Deep convert hash keys from camelCase to snake_case
|
238
|
+
def self.deep_snake_case_keys(obj)
|
239
|
+
case obj
|
240
|
+
when Hash
|
241
|
+
obj.transform_keys { |key| to_snake_case(key.to_s) }
|
242
|
+
.transform_values { |value| deep_snake_case_keys(value) }
|
243
|
+
when Array
|
244
|
+
obj.map { |item| deep_snake_case_keys(item) }
|
245
|
+
else
|
246
|
+
obj
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# Deep convert hash keys from snake_case to camelCase
|
251
|
+
def self.deep_camel_case_keys(obj)
|
252
|
+
case obj
|
253
|
+
when Hash
|
254
|
+
obj.transform_keys { |key| to_camel_case(key.to_s) }
|
255
|
+
.transform_values { |value| deep_camel_case_keys(value) }
|
256
|
+
when Array
|
257
|
+
obj.map { |item| deep_camel_case_keys(item) }
|
258
|
+
else
|
259
|
+
obj
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'whatsapp_cloud_api/version'
|
4
|
+
require_relative 'whatsapp_cloud_api/client'
|
5
|
+
require_relative 'whatsapp_cloud_api/errors'
|
6
|
+
require_relative 'whatsapp_cloud_api/types'
|
7
|
+
require_relative 'whatsapp_cloud_api/resources/messages'
|
8
|
+
require_relative 'whatsapp_cloud_api/resources/media'
|
9
|
+
require_relative 'whatsapp_cloud_api/resources/templates'
|
10
|
+
require_relative 'whatsapp_cloud_api/resources/phone_numbers'
|
11
|
+
require_relative 'whatsapp_cloud_api/resources/calls'
|
12
|
+
require_relative 'whatsapp_cloud_api/resources/conversations'
|
13
|
+
require_relative 'whatsapp_cloud_api/resources/contacts'
|
14
|
+
|
15
|
+
module WhatsAppCloudApi
|
16
|
+
class << self
|
17
|
+
# Configure default logging
|
18
|
+
def logger
|
19
|
+
@logger ||= Logger.new($stdout).tap do |log|
|
20
|
+
log.level = Logger::INFO
|
21
|
+
log.formatter = proc do |severity, datetime, progname, msg|
|
22
|
+
"[#{datetime}] #{severity} #{progname}: #{msg}\n"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def logger=(logger)
|
28
|
+
@logger = logger
|
29
|
+
end
|
30
|
+
|
31
|
+
# Global configuration
|
32
|
+
def configure
|
33
|
+
yield(configuration)
|
34
|
+
end
|
35
|
+
|
36
|
+
def configuration
|
37
|
+
@configuration ||= Configuration.new
|
38
|
+
end
|
39
|
+
|
40
|
+
def reset_configuration!
|
41
|
+
@configuration = Configuration.new
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
class Configuration
|
46
|
+
attr_accessor :debug, :timeout, :open_timeout, :max_retries, :retry_delay,
|
47
|
+
:access_token, :kapso_api_key, :base_url, :api_version
|
48
|
+
|
49
|
+
def initialize
|
50
|
+
@debug = false
|
51
|
+
@timeout = 60
|
52
|
+
@open_timeout = 10
|
53
|
+
@max_retries = 3
|
54
|
+
@retry_delay = 1.0
|
55
|
+
@base_url = 'https://graph.facebook.com'
|
56
|
+
@api_version = 'v23.0'
|
57
|
+
@access_token = nil
|
58
|
+
@kapso_api_key = nil
|
59
|
+
end
|
60
|
+
|
61
|
+
def kapso_proxy?
|
62
|
+
!@kapso_api_key.nil? && @base_url&.include?('kapso')
|
63
|
+
end
|
64
|
+
|
65
|
+
def valid?
|
66
|
+
!@access_token.nil? || !@kapso_api_key.nil?
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|