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.
- checksums.yaml +4 -4
- data/.rubocop.yml +81 -81
- data/CHANGELOG.md +262 -91
- data/Gemfile +20 -20
- data/RAILS_INTEGRATION.md +478 -0
- data/README.md +1053 -734
- data/Rakefile +40 -40
- data/TEMPLATE_TOOLS_GUIDE.md +120 -120
- data/WHATSAPP_24_HOUR_GUIDE.md +133 -133
- data/examples/advanced_features.rb +352 -349
- data/examples/advanced_messaging.rb +241 -0
- data/examples/basic_messaging.rb +139 -136
- data/examples/enhanced_interactive.rb +400 -0
- data/examples/flows_usage.rb +307 -0
- data/examples/interactive_messages.rb +343 -0
- data/examples/media_management.rb +256 -253
- data/examples/rails/jobs.rb +388 -0
- data/examples/rails/models.rb +240 -0
- data/examples/rails/notifications_controller.rb +227 -0
- data/examples/template_management.rb +393 -390
- data/kapso-ruby-logo.jpg +0 -0
- data/lib/kapso_client_ruby/client.rb +321 -316
- data/lib/kapso_client_ruby/errors.rb +348 -329
- data/lib/kapso_client_ruby/rails/generators/install_generator.rb +76 -0
- data/lib/kapso_client_ruby/rails/generators/templates/env.erb +21 -0
- data/lib/kapso_client_ruby/rails/generators/templates/initializer.rb.erb +33 -0
- data/lib/kapso_client_ruby/rails/generators/templates/message_service.rb.erb +138 -0
- data/lib/kapso_client_ruby/rails/generators/templates/webhook_controller.rb.erb +62 -0
- data/lib/kapso_client_ruby/rails/railtie.rb +55 -0
- data/lib/kapso_client_ruby/rails/service.rb +189 -0
- data/lib/kapso_client_ruby/rails/tasks.rake +167 -0
- data/lib/kapso_client_ruby/resources/calls.rb +172 -172
- data/lib/kapso_client_ruby/resources/contacts.rb +190 -190
- data/lib/kapso_client_ruby/resources/conversations.rb +103 -103
- data/lib/kapso_client_ruby/resources/flows.rb +382 -0
- data/lib/kapso_client_ruby/resources/media.rb +205 -205
- data/lib/kapso_client_ruby/resources/messages.rb +760 -380
- data/lib/kapso_client_ruby/resources/phone_numbers.rb +85 -85
- data/lib/kapso_client_ruby/resources/templates.rb +283 -283
- data/lib/kapso_client_ruby/types.rb +348 -262
- data/lib/kapso_client_ruby/version.rb +5 -5
- data/lib/kapso_client_ruby.rb +75 -68
- data/scripts/.env.example +17 -17
- data/scripts/kapso_template_finder.rb +91 -91
- data/scripts/sdk_setup.rb +404 -404
- data/scripts/test.rb +60 -60
- 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
|