kapso-client-ruby 1.0.1 → 1.0.2

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +81 -81
  3. data/CHANGELOG.md +262 -91
  4. data/Gemfile +20 -20
  5. data/RAILS_INTEGRATION.md +477 -477
  6. data/README.md +1053 -752
  7. data/Rakefile +40 -40
  8. data/TEMPLATE_TOOLS_GUIDE.md +120 -120
  9. data/WHATSAPP_24_HOUR_GUIDE.md +133 -133
  10. data/examples/advanced_features.rb +352 -349
  11. data/examples/advanced_messaging.rb +241 -0
  12. data/examples/basic_messaging.rb +139 -136
  13. data/examples/enhanced_interactive.rb +400 -0
  14. data/examples/flows_usage.rb +307 -0
  15. data/examples/interactive_messages.rb +343 -0
  16. data/examples/media_management.rb +256 -253
  17. data/examples/rails/jobs.rb +387 -387
  18. data/examples/rails/models.rb +239 -239
  19. data/examples/rails/notifications_controller.rb +226 -226
  20. data/examples/template_management.rb +393 -390
  21. data/kapso-ruby-logo.jpg +0 -0
  22. data/lib/kapso_client_ruby/client.rb +321 -316
  23. data/lib/kapso_client_ruby/errors.rb +348 -329
  24. data/lib/kapso_client_ruby/rails/generators/install_generator.rb +75 -75
  25. data/lib/kapso_client_ruby/rails/generators/templates/env.erb +20 -20
  26. data/lib/kapso_client_ruby/rails/generators/templates/initializer.rb.erb +32 -32
  27. data/lib/kapso_client_ruby/rails/generators/templates/message_service.rb.erb +137 -137
  28. data/lib/kapso_client_ruby/rails/generators/templates/webhook_controller.rb.erb +61 -61
  29. data/lib/kapso_client_ruby/rails/railtie.rb +54 -54
  30. data/lib/kapso_client_ruby/rails/service.rb +188 -188
  31. data/lib/kapso_client_ruby/rails/tasks.rake +166 -166
  32. data/lib/kapso_client_ruby/resources/calls.rb +172 -172
  33. data/lib/kapso_client_ruby/resources/contacts.rb +190 -190
  34. data/lib/kapso_client_ruby/resources/conversations.rb +103 -103
  35. data/lib/kapso_client_ruby/resources/flows.rb +382 -0
  36. data/lib/kapso_client_ruby/resources/media.rb +205 -205
  37. data/lib/kapso_client_ruby/resources/messages.rb +760 -380
  38. data/lib/kapso_client_ruby/resources/phone_numbers.rb +85 -85
  39. data/lib/kapso_client_ruby/resources/templates.rb +283 -283
  40. data/lib/kapso_client_ruby/types.rb +348 -262
  41. data/lib/kapso_client_ruby/version.rb +5 -5
  42. data/lib/kapso_client_ruby.rb +75 -74
  43. data/scripts/.env.example +17 -17
  44. data/scripts/kapso_template_finder.rb +91 -91
  45. data/scripts/sdk_setup.rb +404 -404
  46. data/scripts/test.rb +60 -60
  47. metadata +12 -3
Binary file
@@ -1,317 +1,322 @@
1
- # frozen_string_literal: true
2
-
3
- require 'faraday'
4
- require 'faraday/multipart'
5
- require 'json'
6
- require 'logger'
7
-
8
- module KapsoClientRuby
9
- class Client
10
- DEFAULT_BASE_URL = 'https://graph.facebook.com'
11
- DEFAULT_GRAPH_VERSION = 'v24.0'
12
- KAPSO_PROXY_PATTERN = /kapso\.ai/
13
-
14
- attr_reader :access_token, :kapso_api_key, :base_url, :graph_version,
15
- :logger, :debug, :timeout, :open_timeout, :max_retries, :retry_delay
16
-
17
- def initialize(access_token: nil, kapso_api_key: nil, base_url: nil,
18
- graph_version: nil, logger: nil, debug: nil, timeout: nil,
19
- open_timeout: nil, max_retries: nil, retry_delay: nil)
20
-
21
- # Validation
22
- unless access_token || kapso_api_key
23
- raise Errors::ConfigurationError, 'Must provide either access_token or kapso_api_key'
24
- end
25
-
26
- @access_token = access_token
27
- @kapso_api_key = kapso_api_key
28
- @base_url = normalize_base_url(base_url || DEFAULT_BASE_URL)
29
- @graph_version = graph_version || DEFAULT_GRAPH_VERSION
30
- @kapso_proxy = detect_kapso_proxy(@base_url)
31
-
32
- # Configuration with defaults
33
- config = KapsoClientRuby.configuration
34
- @logger = logger || KapsoClientRuby.logger
35
- @debug = debug.nil? ? config.debug : debug
36
- @timeout = timeout || config.timeout
37
- @open_timeout = open_timeout || config.open_timeout
38
- @max_retries = max_retries || config.max_retries
39
- @retry_delay = retry_delay || config.retry_delay
40
-
41
- # Initialize HTTP client
42
- @http_client = build_http_client
43
-
44
- # Initialize resource endpoints
45
- @messages = nil
46
- @media = nil
47
- @templates = nil
48
- @phone_numbers = nil
49
- @calls = nil
50
- @conversations = nil
51
- @contacts = nil
52
- end
53
-
54
- # Resource accessors with lazy initialization
55
- def messages
56
- @messages ||= Resources::Messages.new(self)
57
- end
58
-
59
- def media
60
- @media ||= Resources::Media.new(self)
61
- end
62
-
63
- def templates
64
- @templates ||= Resources::Templates.new(self)
65
- end
66
-
67
- def phone_numbers
68
- @phone_numbers ||= Resources::PhoneNumbers.new(self)
69
- end
70
-
71
- def calls
72
- @calls ||= Resources::Calls.new(self)
73
- end
74
-
75
- def conversations
76
- @conversations ||= Resources::Conversations.new(self)
77
- end
78
-
79
- def contacts
80
- @contacts ||= Resources::Contacts.new(self)
81
- end
82
-
83
- def kapso_proxy?
84
- @kapso_proxy
85
- end
86
-
87
- # Main request method with retry logic and error handling
88
- def request(method, path, options = {})
89
- method = method.to_s.upcase
90
- body = options[:body]
91
- query = options[:query]
92
- custom_headers = options[:headers] || {}
93
- response_type = options[:response_type] || :auto
94
-
95
- url = build_url(path, query)
96
- headers = build_headers(custom_headers)
97
-
98
- # Log request if debugging
99
- log_request(method, url, headers, body) if debug
100
-
101
- retries = 0
102
- begin
103
- response = @http_client.run_request(method.downcase.to_sym, url, body, headers)
104
-
105
- # Log response if debugging
106
- log_response(response) if debug
107
-
108
- # Handle response based on type requested
109
- handle_response(response, response_type)
110
- rescue Faraday::Error => e
111
- retries += 1
112
- if retries <= max_retries && retryable_error?(e)
113
- sleep(retry_delay * retries)
114
- retry
115
- else
116
- raise Errors::GraphApiError.new(
117
- message: "Network error: #{e.message}",
118
- http_status: 0,
119
- category: :server
120
- )
121
- end
122
- end
123
- end
124
-
125
- # Raw HTTP method without automatic error handling (for media downloads, etc.)
126
- def raw_request(method, url, options = {})
127
- headers = build_headers(options[:headers] || {})
128
-
129
- log_request(method, url, headers, options[:body]) if debug
130
-
131
- response = @http_client.run_request(method.to_sym, url, options[:body], headers)
132
-
133
- log_response(response) if debug
134
-
135
- response
136
- end
137
-
138
- # Fetch with automatic auth headers (for absolute URLs)
139
- def fetch(url, options = {})
140
- headers = build_headers(options[:headers] || {})
141
- method = options[:method] || 'GET'
142
-
143
- log_request(method, url, headers, options[:body]) if debug
144
-
145
- response = @http_client.run_request(method.downcase.to_sym, url, options[:body], headers)
146
-
147
- log_response(response) if debug
148
-
149
- if response.success?
150
- response
151
- else
152
- handle_error_response(response)
153
- end
154
- end
155
-
156
- private
157
-
158
- def build_http_client
159
- Faraday.new do |f|
160
- f.options.timeout = timeout
161
- f.options.open_timeout = open_timeout
162
- f.request :multipart
163
- f.request :url_encoded
164
- f.adapter Faraday.default_adapter
165
- end
166
- end
167
-
168
- def build_headers(custom_headers = {})
169
- headers = {}
170
-
171
- # Authentication headers
172
- if access_token
173
- headers['Authorization'] = "Bearer #{access_token}"
174
- end
175
-
176
- if kapso_api_key
177
- headers['X-API-Key'] = kapso_api_key
178
- end
179
-
180
- # Default content type for JSON requests
181
- headers['Content-Type'] = 'application/json' unless custom_headers.key?('Content-Type')
182
-
183
- headers.merge(custom_headers.compact)
184
- end
185
-
186
- def build_url(path, query = nil)
187
- # Remove leading slash from path
188
- clean_path = path.to_s.sub(%r{^/}, '')
189
-
190
- # Build base URL with version
191
- base = "#{base_url}/#{graph_version}/"
192
- full_url = URI.join(base, clean_path).to_s
193
-
194
- # Add query parameters if present
195
- if query && !query.empty?
196
- # Convert to snake_case for API (Meta expects snake_case)
197
- snake_query = Types.deep_snake_case_keys(query)
198
- query_string = URI.encode_www_form(flatten_query(snake_query))
199
- separator = full_url.include?('?') ? '&' : '?'
200
- full_url += "#{separator}#{query_string}"
201
- end
202
-
203
- full_url
204
- end
205
-
206
- def flatten_query(query, prefix = nil)
207
- result = []
208
- query.each do |key, value|
209
- param_key = prefix ? "#{prefix}[#{key}]" : key.to_s
210
-
211
- case value
212
- when Hash
213
- result.concat(flatten_query(value, param_key))
214
- when Array
215
- value.each { |v| result << [param_key, v] }
216
- else
217
- result << [param_key, value] unless value.nil?
218
- end
219
- end
220
- result
221
- end
222
-
223
- def handle_response(response, response_type)
224
- unless response.success?
225
- handle_error_response(response)
226
- end
227
-
228
- case response_type
229
- when :json
230
- parse_json_response(response)
231
- when :raw
232
- response
233
- when :auto
234
- content_type = response.headers['content-type'] || ''
235
- if content_type.include?('application/json')
236
- parse_json_response(response)
237
- elsif response.status == 204
238
- Types::GraphSuccessResponse.new
239
- else
240
- response.body
241
- end
242
- else
243
- response.body
244
- end
245
- end
246
-
247
- def parse_json_response(response)
248
- return Types::GraphSuccessResponse.new if response.body.nil? || response.body.strip.empty?
249
-
250
- begin
251
- json = JSON.parse(response.body)
252
- # Convert camelCase keys to snake_case for Ruby conventions
253
- Types.deep_snake_case_keys(json)
254
- rescue JSON::ParserError => e
255
- raise Errors::GraphApiError.new(
256
- message: "Invalid JSON response: #{e.message}",
257
- http_status: response.status,
258
- raw_response: response.body
259
- )
260
- end
261
- end
262
-
263
- def handle_error_response(response)
264
- body = response.body
265
-
266
- # Try to parse JSON error
267
- begin
268
- json_body = JSON.parse(body) if body && !body.strip.empty?
269
- rescue JSON::ParserError
270
- json_body = nil
271
- end
272
-
273
- # Create error with proper parameters
274
- raise Errors::GraphApiError.from_response(response, json_body || {}, body)
275
- end
276
-
277
- def retryable_error?(error)
278
- # Only retry on network errors, not HTTP errors
279
- error.is_a?(Faraday::TimeoutError) ||
280
- error.is_a?(Faraday::ConnectionFailed) ||
281
- error.is_a?(Faraday::ServerError)
282
- end
283
-
284
- def normalize_base_url(url)
285
- url = url.to_s
286
- url = "https://#{url}" unless url.match?(%r{^https?://})
287
- url.chomp('/')
288
- end
289
-
290
- def detect_kapso_proxy(url)
291
- url.match?(KAPSO_PROXY_PATTERN)
292
- end
293
-
294
- def log_request(method, url, headers, body)
295
- logger.debug "WhatsApp API Request: #{method} #{url}"
296
- logger.debug "Headers: #{headers.inspect}" if headers.any?
297
-
298
- if body
299
- if body.is_a?(String)
300
- logger.debug "Body: #{body.length > 1000 ? "#{body[0..1000]}..." : body}"
301
- else
302
- logger.debug "Body: #{body.inspect}"
303
- end
304
- end
305
- end
306
-
307
- def log_response(response)
308
- logger.debug "WhatsApp API Response: #{response.status}"
309
- logger.debug "Response Headers: #{response.headers.to_h.inspect}"
310
-
311
- if response.body
312
- body_preview = response.body.length > 1000 ? "#{response.body[0..1000]}..." : response.body
313
- logger.debug "Response Body: #{body_preview}"
314
- end
315
- end
316
- end
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'json'
6
+ require 'logger'
7
+
8
+ module KapsoClientRuby
9
+ class Client
10
+ DEFAULT_BASE_URL = 'https://graph.facebook.com'
11
+ DEFAULT_GRAPH_VERSION = 'v24.0'
12
+ KAPSO_PROXY_PATTERN = /kapso\.ai/
13
+
14
+ attr_reader :access_token, :kapso_api_key, :base_url, :graph_version,
15
+ :logger, :debug, :timeout, :open_timeout, :max_retries, :retry_delay
16
+
17
+ def initialize(access_token: nil, kapso_api_key: nil, base_url: nil,
18
+ graph_version: nil, logger: nil, debug: nil, timeout: nil,
19
+ open_timeout: nil, max_retries: nil, retry_delay: nil)
20
+
21
+ # Validation
22
+ unless access_token || kapso_api_key
23
+ raise Errors::ConfigurationError, 'Must provide either access_token or kapso_api_key'
24
+ end
25
+
26
+ @access_token = access_token
27
+ @kapso_api_key = kapso_api_key
28
+ @base_url = normalize_base_url(base_url || DEFAULT_BASE_URL)
29
+ @graph_version = graph_version || DEFAULT_GRAPH_VERSION
30
+ @kapso_proxy = detect_kapso_proxy(@base_url)
31
+
32
+ # Configuration with defaults
33
+ config = KapsoClientRuby.configuration
34
+ @logger = logger || KapsoClientRuby.logger
35
+ @debug = debug.nil? ? config.debug : debug
36
+ @timeout = timeout || config.timeout
37
+ @open_timeout = open_timeout || config.open_timeout
38
+ @max_retries = max_retries || config.max_retries
39
+ @retry_delay = retry_delay || config.retry_delay
40
+
41
+ # Initialize HTTP client
42
+ @http_client = build_http_client
43
+
44
+ # Initialize resource endpoints
45
+ @messages = nil
46
+ @media = nil
47
+ @templates = nil
48
+ @phone_numbers = nil
49
+ @calls = nil
50
+ @conversations = nil
51
+ @contacts = nil
52
+ @flows = nil
53
+ end
54
+
55
+ # Resource accessors with lazy initialization
56
+ def messages
57
+ @messages ||= Resources::Messages.new(self)
58
+ end
59
+
60
+ def media
61
+ @media ||= Resources::Media.new(self)
62
+ end
63
+
64
+ def templates
65
+ @templates ||= Resources::Templates.new(self)
66
+ end
67
+
68
+ def phone_numbers
69
+ @phone_numbers ||= Resources::PhoneNumbers.new(self)
70
+ end
71
+
72
+ def calls
73
+ @calls ||= Resources::Calls.new(self)
74
+ end
75
+
76
+ def conversations
77
+ @conversations ||= Resources::Conversations.new(self)
78
+ end
79
+
80
+ def contacts
81
+ @contacts ||= Resources::Contacts.new(self)
82
+ end
83
+
84
+ def flows
85
+ @flows ||= Resources::Flows.new(self)
86
+ end
87
+
88
+ def kapso_proxy?
89
+ @kapso_proxy
90
+ end
91
+
92
+ # Main request method with retry logic and error handling
93
+ def request(method, path, options = {})
94
+ method = method.to_s.upcase
95
+ body = options[:body]
96
+ query = options[:query]
97
+ custom_headers = options[:headers] || {}
98
+ response_type = options[:response_type] || :auto
99
+
100
+ url = build_url(path, query)
101
+ headers = build_headers(custom_headers)
102
+
103
+ # Log request if debugging
104
+ log_request(method, url, headers, body) if debug
105
+
106
+ retries = 0
107
+ begin
108
+ response = @http_client.run_request(method.downcase.to_sym, url, body, headers)
109
+
110
+ # Log response if debugging
111
+ log_response(response) if debug
112
+
113
+ # Handle response based on type requested
114
+ handle_response(response, response_type)
115
+ rescue Faraday::Error => e
116
+ retries += 1
117
+ if retries <= max_retries && retryable_error?(e)
118
+ sleep(retry_delay * retries)
119
+ retry
120
+ else
121
+ raise Errors::GraphApiError.new(
122
+ message: "Network error: #{e.message}",
123
+ http_status: 0,
124
+ category: :server
125
+ )
126
+ end
127
+ end
128
+ end
129
+
130
+ # Raw HTTP method without automatic error handling (for media downloads, etc.)
131
+ def raw_request(method, url, options = {})
132
+ headers = build_headers(options[:headers] || {})
133
+
134
+ log_request(method, url, headers, options[:body]) if debug
135
+
136
+ response = @http_client.run_request(method.to_sym, url, options[:body], headers)
137
+
138
+ log_response(response) if debug
139
+
140
+ response
141
+ end
142
+
143
+ # Fetch with automatic auth headers (for absolute URLs)
144
+ def fetch(url, options = {})
145
+ headers = build_headers(options[:headers] || {})
146
+ method = options[:method] || 'GET'
147
+
148
+ log_request(method, url, headers, options[:body]) if debug
149
+
150
+ response = @http_client.run_request(method.downcase.to_sym, url, options[:body], headers)
151
+
152
+ log_response(response) if debug
153
+
154
+ if response.success?
155
+ response
156
+ else
157
+ handle_error_response(response)
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ def build_http_client
164
+ Faraday.new do |f|
165
+ f.options.timeout = timeout
166
+ f.options.open_timeout = open_timeout
167
+ f.request :multipart
168
+ f.request :url_encoded
169
+ f.adapter Faraday.default_adapter
170
+ end
171
+ end
172
+
173
+ def build_headers(custom_headers = {})
174
+ headers = {}
175
+
176
+ # Authentication headers
177
+ if access_token
178
+ headers['Authorization'] = "Bearer #{access_token}"
179
+ end
180
+
181
+ if kapso_api_key
182
+ headers['X-API-Key'] = kapso_api_key
183
+ end
184
+
185
+ # Default content type for JSON requests
186
+ headers['Content-Type'] = 'application/json' unless custom_headers.key?('Content-Type')
187
+
188
+ headers.merge(custom_headers.compact)
189
+ end
190
+
191
+ def build_url(path, query = nil)
192
+ # Remove leading slash from path
193
+ clean_path = path.to_s.sub(%r{^/}, '')
194
+
195
+ # Build base URL with version
196
+ base = "#{base_url}/#{graph_version}/"
197
+ full_url = URI.join(base, clean_path).to_s
198
+
199
+ # Add query parameters if present
200
+ if query && !query.empty?
201
+ # Convert to snake_case for API (Meta expects snake_case)
202
+ snake_query = Types.deep_snake_case_keys(query)
203
+ query_string = URI.encode_www_form(flatten_query(snake_query))
204
+ separator = full_url.include?('?') ? '&' : '?'
205
+ full_url += "#{separator}#{query_string}"
206
+ end
207
+
208
+ full_url
209
+ end
210
+
211
+ def flatten_query(query, prefix = nil)
212
+ result = []
213
+ query.each do |key, value|
214
+ param_key = prefix ? "#{prefix}[#{key}]" : key.to_s
215
+
216
+ case value
217
+ when Hash
218
+ result.concat(flatten_query(value, param_key))
219
+ when Array
220
+ value.each { |v| result << [param_key, v] }
221
+ else
222
+ result << [param_key, value] unless value.nil?
223
+ end
224
+ end
225
+ result
226
+ end
227
+
228
+ def handle_response(response, response_type)
229
+ unless response.success?
230
+ handle_error_response(response)
231
+ end
232
+
233
+ case response_type
234
+ when :json
235
+ parse_json_response(response)
236
+ when :raw
237
+ response
238
+ when :auto
239
+ content_type = response.headers['content-type'] || ''
240
+ if content_type.include?('application/json')
241
+ parse_json_response(response)
242
+ elsif response.status == 204
243
+ Types::GraphSuccessResponse.new
244
+ else
245
+ response.body
246
+ end
247
+ else
248
+ response.body
249
+ end
250
+ end
251
+
252
+ def parse_json_response(response)
253
+ return Types::GraphSuccessResponse.new if response.body.nil? || response.body.strip.empty?
254
+
255
+ begin
256
+ json = JSON.parse(response.body)
257
+ # Convert camelCase keys to snake_case for Ruby conventions
258
+ Types.deep_snake_case_keys(json)
259
+ rescue JSON::ParserError => e
260
+ raise Errors::GraphApiError.new(
261
+ message: "Invalid JSON response: #{e.message}",
262
+ http_status: response.status,
263
+ raw_response: response.body
264
+ )
265
+ end
266
+ end
267
+
268
+ def handle_error_response(response)
269
+ body = response.body
270
+
271
+ # Try to parse JSON error
272
+ begin
273
+ json_body = JSON.parse(body) if body && !body.strip.empty?
274
+ rescue JSON::ParserError
275
+ json_body = nil
276
+ end
277
+
278
+ # Create error with proper parameters
279
+ raise Errors::GraphApiError.from_response(response, json_body || {}, body)
280
+ end
281
+
282
+ def retryable_error?(error)
283
+ # Only retry on network errors, not HTTP errors
284
+ error.is_a?(Faraday::TimeoutError) ||
285
+ error.is_a?(Faraday::ConnectionFailed) ||
286
+ error.is_a?(Faraday::ServerError)
287
+ end
288
+
289
+ def normalize_base_url(url)
290
+ url = url.to_s
291
+ url = "https://#{url}" unless url.match?(%r{^https?://})
292
+ url.chomp('/')
293
+ end
294
+
295
+ def detect_kapso_proxy(url)
296
+ url.match?(KAPSO_PROXY_PATTERN)
297
+ end
298
+
299
+ def log_request(method, url, headers, body)
300
+ logger.debug "WhatsApp API Request: #{method} #{url}"
301
+ logger.debug "Headers: #{headers.inspect}" if headers.any?
302
+
303
+ if body
304
+ if body.is_a?(String)
305
+ logger.debug "Body: #{body.length > 1000 ? "#{body[0..1000]}..." : body}"
306
+ else
307
+ logger.debug "Body: #{body.inspect}"
308
+ end
309
+ end
310
+ end
311
+
312
+ def log_response(response)
313
+ logger.debug "WhatsApp API Response: #{response.status}"
314
+ logger.debug "Response Headers: #{response.headers.to_h.inspect}"
315
+
316
+ if response.body
317
+ body_preview = response.body.length > 1000 ? "#{response.body[0..1000]}..." : response.body
318
+ logger.debug "Response Body: #{body_preview}"
319
+ end
320
+ end
321
+ end
317
322
  end