kapso-client-ruby 1.0.0 → 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 +478 -0
  6. data/README.md +1053 -734
  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 +388 -0
  18. data/examples/rails/models.rb +240 -0
  19. data/examples/rails/notifications_controller.rb +227 -0
  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 +76 -0
  25. data/lib/kapso_client_ruby/rails/generators/templates/env.erb +21 -0
  26. data/lib/kapso_client_ruby/rails/generators/templates/initializer.rb.erb +33 -0
  27. data/lib/kapso_client_ruby/rails/generators/templates/message_service.rb.erb +138 -0
  28. data/lib/kapso_client_ruby/rails/generators/templates/webhook_controller.rb.erb +62 -0
  29. data/lib/kapso_client_ruby/rails/railtie.rb +55 -0
  30. data/lib/kapso_client_ruby/rails/service.rb +189 -0
  31. data/lib/kapso_client_ruby/rails/tasks.rake +167 -0
  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 -68
  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 +24 -3
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'json'
6
+
7
+ module KapsoClientRuby
8
+ module Resources
9
+ # Manages WhatsApp Flows - interactive forms and data collection
10
+ # Flows allow you to build rich, multi-step experiences within WhatsApp
11
+ class Flows
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # Create a new Flow
17
+ # @param business_account_id [String] WhatsApp Business Account ID
18
+ # @param name [String] Flow name
19
+ # @param categories [Array<String>] Flow categories (e.g., ['APPOINTMENT_BOOKING'])
20
+ # @param options [Hash] Additional options
21
+ # @option options [String] :endpoint_uri Data endpoint URL for Flow callbacks
22
+ # @option options [String] :application_id Application ID for the Flow
23
+ # @return [Hash] Created Flow data with ID
24
+ def create(business_account_id:, name:, categories: ['OTHER'], **options)
25
+ payload = {
26
+ name: name,
27
+ categories: categories
28
+ }
29
+
30
+ payload[:endpoint_uri] = options[:endpoint_uri] if options[:endpoint_uri]
31
+ payload[:application_id] = options[:application_id] if options[:application_id]
32
+
33
+ response = @client.request(
34
+ :post,
35
+ "#{business_account_id}/flows",
36
+ body: payload.to_json,
37
+ response_type: :json
38
+ )
39
+
40
+ Types::FlowResponse.new(response)
41
+ end
42
+
43
+ # Update an existing Flow
44
+ # @param flow_id [String] Flow ID
45
+ # @param attributes [Hash] Attributes to update (name, categories, endpoint_uri, application_id)
46
+ # @return [Hash] Updated Flow data
47
+ def update(flow_id:, **attributes)
48
+ valid_attributes = [:name, :categories, :endpoint_uri, :application_id]
49
+ payload = attributes.select { |k, _| valid_attributes.include?(k) }
50
+
51
+ raise ArgumentError, 'No valid attributes provided' if payload.empty?
52
+
53
+ response = @client.request(
54
+ :post,
55
+ "#{flow_id}",
56
+ body: payload.to_json,
57
+ response_type: :json
58
+ )
59
+
60
+ Types::FlowResponse.new(response)
61
+ end
62
+
63
+ # Delete a Flow
64
+ # @param flow_id [String] Flow ID
65
+ # @return [Hash] Success response
66
+ def delete(flow_id:)
67
+ response = @client.request(
68
+ :delete,
69
+ "#{flow_id}",
70
+ response_type: :json
71
+ )
72
+
73
+ Types::GraphSuccessResponse.new(response)
74
+ end
75
+
76
+ # Get Flow details
77
+ # @param flow_id [String] Flow ID
78
+ # @param fields [Array<String>, nil] Specific fields to retrieve
79
+ # @return [Hash] Flow data
80
+ def get(flow_id:, fields: nil)
81
+ query_params = {}
82
+ query_params[:fields] = fields.join(',') if fields
83
+
84
+ response = @client.request(
85
+ :get,
86
+ "#{flow_id}",
87
+ query: query_params,
88
+ response_type: :json
89
+ )
90
+
91
+ Types::FlowData.new(response)
92
+ end
93
+
94
+ # List all Flows for a business account
95
+ # @param business_account_id [String] WhatsApp Business Account ID
96
+ # @param fields [Array<String>, nil] Specific fields to retrieve
97
+ # @return [Hash] List of Flows
98
+ def list(business_account_id:, fields: nil)
99
+ query_params = {}
100
+ query_params[:fields] = fields.join(',') if fields
101
+
102
+ response = @client.request(
103
+ :get,
104
+ "#{business_account_id}/flows",
105
+ query: query_params,
106
+ response_type: :json
107
+ )
108
+
109
+ Types::PagedResponse.new(response)
110
+ end
111
+
112
+ # Publish a Flow
113
+ # @param flow_id [String] Flow ID
114
+ # @param phone_number_id [String, nil] Phone number ID (for query params)
115
+ # @param business_account_id [String, nil] Business account ID (for query params)
116
+ # @return [Hash] Success response
117
+ def publish(flow_id:, phone_number_id: nil, business_account_id: nil)
118
+ query_params = build_query_params(phone_number_id, business_account_id)
119
+
120
+ response = @client.request(
121
+ :post,
122
+ "#{flow_id}/publish",
123
+ query: query_params,
124
+ body: {}.to_json,
125
+ response_type: :json
126
+ )
127
+
128
+ Types::GraphSuccessResponse.new(response)
129
+ end
130
+
131
+ # Deprecate a Flow
132
+ # @param flow_id [String] Flow ID
133
+ # @param phone_number_id [String, nil] Phone number ID (for query params)
134
+ # @param business_account_id [String, nil] Business account ID (for query params)
135
+ # @return [Hash] Success response
136
+ def deprecate(flow_id:, phone_number_id: nil, business_account_id: nil)
137
+ query_params = build_query_params(phone_number_id, business_account_id)
138
+
139
+ response = @client.request(
140
+ :post,
141
+ "#{flow_id}/deprecate",
142
+ query: query_params,
143
+ body: {}.to_json,
144
+ response_type: :json
145
+ )
146
+
147
+ Types::GraphSuccessResponse.new(response)
148
+ end
149
+
150
+ # Update Flow asset (JSON definition)
151
+ # @param flow_id [String] Flow ID
152
+ # @param asset [Hash, String] Flow JSON definition (Hash or JSON string)
153
+ # @param phone_number_id [String, nil] Phone number ID (for query params)
154
+ # @param business_account_id [String, nil] Business account ID (for query params)
155
+ # @return [Hash] Asset update response with validation results
156
+ def update_asset(flow_id:, asset:, phone_number_id: nil, business_account_id: nil)
157
+ query_params = build_query_params(phone_number_id, business_account_id)
158
+
159
+ # Convert asset to JSON string if it's a Hash
160
+ asset_json = asset.is_a?(String) ? asset : asset.to_json
161
+
162
+ # Create multipart form data
163
+ payload = {
164
+ messaging_product: 'whatsapp',
165
+ asset_type: 'FLOW_JSON',
166
+ asset: asset_json
167
+ }
168
+
169
+ response = @client.request(
170
+ :post,
171
+ "#{flow_id}/assets",
172
+ query: query_params,
173
+ body: payload.to_json,
174
+ response_type: :json
175
+ )
176
+
177
+ Types::FlowAssetResponse.new(response)
178
+ end
179
+
180
+ # Get Flow preview URL
181
+ # @param flow_id [String] Flow ID
182
+ # @param phone_number_id [String, nil] Phone number ID (for query params)
183
+ # @param business_account_id [String, nil] Business account ID (for query params)
184
+ # @return [Hash] Preview URL response
185
+ def preview(flow_id:, phone_number_id: nil, business_account_id: nil)
186
+ query_params = build_query_params(phone_number_id, business_account_id)
187
+ query_params[:fields] = 'preview.preview_url,preview.expires_at'
188
+
189
+ response = @client.request(
190
+ :get,
191
+ "#{flow_id}",
192
+ query: query_params,
193
+ response_type: :json
194
+ )
195
+
196
+ Types::FlowPreviewResponse.new(response)
197
+ end
198
+
199
+ # Idempotent Flow deployment - creates or updates, then publishes
200
+ # @param business_account_id [String] WhatsApp Business Account ID
201
+ # @param name [String] Flow name
202
+ # @param flow_json [Hash] Flow JSON definition
203
+ # @param categories [Array<String>] Flow categories
204
+ # @param endpoint_uri [String, nil] Data endpoint URL
205
+ # @param application_id [String, nil] Application ID
206
+ # @return [Hash] Deployment result with flow ID and status
207
+ def deploy(business_account_id:, name:, flow_json:, categories: ['OTHER'],
208
+ endpoint_uri: nil, application_id: nil)
209
+ # Check if flow exists by name
210
+ existing_flows = list(business_account_id: business_account_id)
211
+ flow = existing_flows.dig('data')&.find { |f| f['name'] == name }
212
+
213
+ if flow.nil?
214
+ # Create new flow
215
+ @client.logger.debug "Creating new Flow: #{name}"
216
+ created = create(
217
+ business_account_id: business_account_id,
218
+ name: name,
219
+ categories: categories,
220
+ endpoint_uri: endpoint_uri,
221
+ application_id: application_id
222
+ )
223
+ flow_id = created['id']
224
+ else
225
+ # Use existing flow
226
+ flow_id = flow['id']
227
+ @client.logger.debug "Using existing Flow: #{name} (#{flow_id})"
228
+
229
+ # Update attributes if provided
230
+ update_attrs = {}
231
+ update_attrs[:categories] = categories if categories != ['OTHER']
232
+ update_attrs[:endpoint_uri] = endpoint_uri if endpoint_uri
233
+ update_attrs[:application_id] = application_id if application_id
234
+
235
+ unless update_attrs.empty?
236
+ update(flow_id: flow_id, **update_attrs)
237
+ end
238
+ end
239
+
240
+ # Update asset
241
+ @client.logger.debug "Updating Flow asset for #{flow_id}"
242
+ update_asset(flow_id: flow_id, asset: flow_json)
243
+
244
+ # Publish
245
+ @client.logger.debug "Publishing Flow #{flow_id}"
246
+ publish(flow_id: flow_id)
247
+
248
+ {
249
+ id: flow_id,
250
+ name: name,
251
+ status: 'published',
252
+ message: flow.nil? ? 'Flow created and published' : 'Flow updated and published'
253
+ }
254
+ end
255
+
256
+ # Receive and decrypt Flow event from webhook
257
+ # @param encrypted_request [String] Encrypted request body from webhook
258
+ # @param private_key [String, OpenSSL::PKey::RSA] Private key for decryption (PEM format or key object)
259
+ # @param passphrase [String, nil] Passphrase for encrypted private key
260
+ # @return [Hash] Decrypted Flow event data
261
+ def receive_flow_event(encrypted_request:, private_key:, passphrase: nil)
262
+ # Parse encrypted request
263
+ request_data = JSON.parse(encrypted_request)
264
+
265
+ # Extract encrypted components
266
+ encrypted_aes_key = Base64.decode64(request_data['encrypted_aes_key'])
267
+ encrypted_flow_data = Base64.decode64(request_data['encrypted_flow_data'])
268
+ initial_vector = Base64.decode64(request_data['initial_vector'])
269
+
270
+ # Load private key if it's a string
271
+ rsa_key = if private_key.is_a?(String)
272
+ OpenSSL::PKey::RSA.new(private_key, passphrase)
273
+ else
274
+ private_key
275
+ end
276
+
277
+ # Decrypt AES key using RSA private key
278
+ aes_key = rsa_key.private_decrypt(encrypted_aes_key)
279
+
280
+ # Decrypt flow data using AES
281
+ cipher = OpenSSL::Cipher.new('AES-128-GCM')
282
+ cipher.decrypt
283
+ cipher.key = aes_key
284
+ cipher.iv = initial_vector
285
+
286
+ # Extract authentication tag (last 16 bytes)
287
+ auth_tag = encrypted_flow_data[-16..]
288
+ ciphertext = encrypted_flow_data[0...-16]
289
+ cipher.auth_tag = auth_tag
290
+
291
+ decrypted_data = cipher.update(ciphertext) + cipher.final
292
+
293
+ # Parse and return decrypted JSON
294
+ flow_event = JSON.parse(decrypted_data)
295
+ Types::FlowEventData.new(flow_event)
296
+ rescue OpenSSL::PKey::RSAError => e
297
+ raise Errors::FlowDecryptionError.new("Failed to decrypt with private key: #{e.message}")
298
+ rescue OpenSSL::Cipher::CipherError => e
299
+ raise Errors::FlowDecryptionError.new("Failed to decrypt flow data: #{e.message}")
300
+ rescue JSON::ParserError => e
301
+ raise Errors::FlowDecryptionError.new("Invalid JSON in encrypted request: #{e.message}")
302
+ end
303
+
304
+ # Encrypt and send response to Flow
305
+ # @param response_data [Hash] Response data to send to Flow
306
+ # @param private_key [String, OpenSSL::PKey::RSA] Private key for signing (PEM format or key object)
307
+ # @param passphrase [String, nil] Passphrase for encrypted private key
308
+ # @return [String] Encrypted response JSON
309
+ def respond_to_flow(response_data:, private_key:, passphrase: nil)
310
+ # Load private key if it's a string
311
+ rsa_key = if private_key.is_a?(String)
312
+ OpenSSL::PKey::RSA.new(private_key, passphrase)
313
+ else
314
+ private_key
315
+ end
316
+
317
+ # Generate random AES key and IV
318
+ aes_key = OpenSSL::Cipher.new('AES-128-GCM').random_key
319
+ initial_vector = OpenSSL::Cipher.new('AES-128-GCM').random_iv
320
+
321
+ # Encrypt response data using AES
322
+ cipher = OpenSSL::Cipher.new('AES-128-GCM')
323
+ cipher.encrypt
324
+ cipher.key = aes_key
325
+ cipher.iv = initial_vector
326
+
327
+ response_json = response_data.to_json
328
+ encrypted_data = cipher.update(response_json) + cipher.final
329
+ auth_tag = cipher.auth_tag
330
+
331
+ # Combine ciphertext and auth tag
332
+ encrypted_flow_data = encrypted_data + auth_tag
333
+
334
+ # Encrypt AES key using RSA public key
335
+ encrypted_aes_key = rsa_key.public_encrypt(aes_key)
336
+
337
+ # Build encrypted response
338
+ encrypted_response = {
339
+ encrypted_aes_key: Base64.encode64(encrypted_aes_key),
340
+ encrypted_flow_data: Base64.encode64(encrypted_flow_data),
341
+ initial_vector: Base64.encode64(initial_vector)
342
+ }
343
+
344
+ encrypted_response.to_json
345
+ rescue OpenSSL::PKey::RSAError => e
346
+ raise Errors::FlowEncryptionError.new("Failed to encrypt with private key: #{e.message}")
347
+ rescue OpenSSL::Cipher::CipherError => e
348
+ raise Errors::FlowEncryptionError.new("Failed to encrypt flow response: #{e.message}")
349
+ end
350
+
351
+ # Download media from Flow
352
+ # @param media_url [String] Media URL from Flow event
353
+ # @param access_token [String, nil] Access token (uses client token if not provided)
354
+ # @return [String] Binary media content
355
+ def download_flow_media(media_url:, access_token: nil)
356
+ token = access_token || @client.access_token
357
+
358
+ unless token
359
+ raise Errors::ConfigurationError, 'Access token required to download Flow media'
360
+ end
361
+
362
+ # Make authenticated request to media URL
363
+ response = @client.fetch(
364
+ media_url,
365
+ headers: { 'Authorization' => "Bearer #{token}" }
366
+ )
367
+
368
+ response.body
369
+ end
370
+
371
+ private
372
+
373
+ # Build query parameters for Flow operations
374
+ def build_query_params(phone_number_id, business_account_id)
375
+ params = {}
376
+ params[:phone_number_id] = phone_number_id if phone_number_id
377
+ params[:business_account_id] = business_account_id if business_account_id
378
+ params
379
+ end
380
+ end
381
+ end
382
+ end