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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
4
+ VERSION = '1.0.0'
5
+ 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