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
@@ -1,381 +1,761 @@
1
- # frozen_string_literal: true
2
-
3
- module KapsoClientRuby
4
- module Resources
5
- class Messages
6
- def initialize(client)
7
- @client = client
8
- end
9
-
10
- # Text Messages
11
- def send_text(phone_number_id:, to:, body:, preview_url: nil, context_message_id: nil,
12
- biz_opaque_callback_data: nil)
13
- payload = build_base_payload(
14
- phone_number_id: phone_number_id,
15
- to: to,
16
- type: 'text',
17
- context_message_id: context_message_id,
18
- biz_opaque_callback_data: biz_opaque_callback_data
19
- )
20
-
21
- payload[:text] = { body: body }
22
- payload[:text][:preview_url] = preview_url unless preview_url.nil?
23
-
24
- response = @client.request(:post, "#{phone_number_id}/messages",
25
- body: payload.to_json, response_type: :json)
26
- Types::SendMessageResponse.new(response)
27
- end
28
-
29
- # Image Messages
30
- def send_image(phone_number_id:, to:, image:, caption: nil, context_message_id: nil,
31
- biz_opaque_callback_data: nil)
32
- payload = build_base_payload(
33
- phone_number_id: phone_number_id,
34
- to: to,
35
- type: 'image',
36
- context_message_id: context_message_id,
37
- biz_opaque_callback_data: biz_opaque_callback_data
38
- )
39
-
40
- image_obj = build_media_object(image, caption)
41
- payload[:image] = image_obj
42
-
43
- response = @client.request(:post, "#{phone_number_id}/messages",
44
- body: payload.to_json, response_type: :json)
45
- Types::SendMessageResponse.new(response)
46
- end
47
-
48
- # Audio Messages
49
- def send_audio(phone_number_id:, to:, audio:, context_message_id: nil,
50
- biz_opaque_callback_data: nil)
51
- payload = build_base_payload(
52
- phone_number_id: phone_number_id,
53
- to: to,
54
- type: 'audio',
55
- context_message_id: context_message_id,
56
- biz_opaque_callback_data: biz_opaque_callback_data
57
- )
58
-
59
- payload[:audio] = build_media_object(audio)
60
-
61
- response = @client.request(:post, "#{phone_number_id}/messages",
62
- body: payload.to_json, response_type: :json)
63
- Types::SendMessageResponse.new(response)
64
- end
65
-
66
- # Document Messages
67
- def send_document(phone_number_id:, to:, document:, caption: nil, filename: nil,
68
- context_message_id: nil, biz_opaque_callback_data: nil)
69
- payload = build_base_payload(
70
- phone_number_id: phone_number_id,
71
- to: to,
72
- type: 'document',
73
- context_message_id: context_message_id,
74
- biz_opaque_callback_data: biz_opaque_callback_data
75
- )
76
-
77
- document_obj = build_media_object(document, caption)
78
- document_obj[:filename] = filename if filename
79
- payload[:document] = document_obj
80
-
81
- response = @client.request(:post, "#{phone_number_id}/messages",
82
- body: payload.to_json, response_type: :json)
83
- Types::SendMessageResponse.new(response)
84
- end
85
-
86
- # Video Messages
87
- def send_video(phone_number_id:, to:, video:, caption: nil, context_message_id: nil,
88
- biz_opaque_callback_data: nil)
89
- payload = build_base_payload(
90
- phone_number_id: phone_number_id,
91
- to: to,
92
- type: 'video',
93
- context_message_id: context_message_id,
94
- biz_opaque_callback_data: biz_opaque_callback_data
95
- )
96
-
97
- payload[:video] = build_media_object(video, caption)
98
-
99
- response = @client.request(:post, "#{phone_number_id}/messages",
100
- body: payload.to_json, response_type: :json)
101
- Types::SendMessageResponse.new(response)
102
- end
103
-
104
- # Sticker Messages
105
- def send_sticker(phone_number_id:, to:, sticker:, context_message_id: nil,
106
- biz_opaque_callback_data: nil)
107
- payload = build_base_payload(
108
- phone_number_id: phone_number_id,
109
- to: to,
110
- type: 'sticker',
111
- context_message_id: context_message_id,
112
- biz_opaque_callback_data: biz_opaque_callback_data
113
- )
114
-
115
- payload[:sticker] = build_media_object(sticker)
116
-
117
- response = @client.request(:post, "#{phone_number_id}/messages",
118
- body: payload.to_json, response_type: :json)
119
- Types::SendMessageResponse.new(response)
120
- end
121
-
122
- # Location Messages
123
- def send_location(phone_number_id:, to:, latitude:, longitude:, name: nil,
124
- address: nil, context_message_id: nil, biz_opaque_callback_data: nil)
125
- payload = build_base_payload(
126
- phone_number_id: phone_number_id,
127
- to: to,
128
- type: 'location',
129
- context_message_id: context_message_id,
130
- biz_opaque_callback_data: biz_opaque_callback_data
131
- )
132
-
133
- location_obj = {
134
- latitude: latitude,
135
- longitude: longitude
136
- }
137
- location_obj[:name] = name if name
138
- location_obj[:address] = address if address
139
-
140
- payload[:location] = location_obj
141
-
142
- response = @client.request(:post, "#{phone_number_id}/messages",
143
- body: payload.to_json, response_type: :json)
144
- Types::SendMessageResponse.new(response)
145
- end
146
-
147
- # Contact Messages
148
- def send_contacts(phone_number_id:, to:, contacts:, context_message_id: nil,
149
- biz_opaque_callback_data: nil)
150
- payload = build_base_payload(
151
- phone_number_id: phone_number_id,
152
- to: to,
153
- type: 'contacts',
154
- context_message_id: context_message_id,
155
- biz_opaque_callback_data: biz_opaque_callback_data
156
- )
157
-
158
- payload[:contacts] = contacts
159
-
160
- response = @client.request(:post, "#{phone_number_id}/messages",
161
- body: payload.to_json, response_type: :json)
162
- Types::SendMessageResponse.new(response)
163
- end
164
-
165
- # Template Messages
166
- def send_template(phone_number_id:, to:, name:, language:, components: nil,
167
- context_message_id: nil, biz_opaque_callback_data: nil)
168
- payload = build_base_payload(
169
- phone_number_id: phone_number_id,
170
- to: to,
171
- type: 'template',
172
- context_message_id: context_message_id,
173
- biz_opaque_callback_data: biz_opaque_callback_data
174
- )
175
-
176
- template_obj = {
177
- name: name,
178
- language: { code: language }
179
- }
180
- template_obj[:components] = components if components
181
-
182
- payload[:template] = template_obj
183
-
184
- response = @client.request(:post, "#{phone_number_id}/messages",
185
- body: payload.to_json, response_type: :json)
186
- Types::SendMessageResponse.new(response)
187
- end
188
-
189
- # Reaction Messages
190
- def send_reaction(phone_number_id:, to:, message_id:, emoji: nil,
191
- context_message_id: nil, biz_opaque_callback_data: nil)
192
- payload = build_base_payload(
193
- phone_number_id: phone_number_id,
194
- to: to,
195
- type: 'reaction',
196
- context_message_id: context_message_id,
197
- biz_opaque_callback_data: biz_opaque_callback_data
198
- )
199
-
200
- reaction_obj = { message_id: message_id }
201
- reaction_obj[:emoji] = emoji if emoji # nil emoji removes reaction
202
-
203
- payload[:reaction] = reaction_obj
204
-
205
- response = @client.request(:post, "#{phone_number_id}/messages",
206
- body: payload.to_json, response_type: :json)
207
- Types::SendMessageResponse.new(response)
208
- end
209
-
210
- # Interactive Button Messages
211
- def send_interactive_buttons(phone_number_id:, to:, body_text:, buttons:,
212
- header: nil, footer: nil, context_message_id: nil,
213
- biz_opaque_callback_data: nil)
214
- payload = build_base_payload(
215
- phone_number_id: phone_number_id,
216
- to: to,
217
- type: 'interactive',
218
- context_message_id: context_message_id,
219
- biz_opaque_callback_data: biz_opaque_callback_data
220
- )
221
-
222
- interactive_obj = {
223
- type: 'button',
224
- body: { text: body_text },
225
- action: { buttons: buttons }
226
- }
227
-
228
- interactive_obj[:header] = header if header
229
- interactive_obj[:footer] = footer if footer
230
-
231
- payload[:interactive] = interactive_obj
232
-
233
- response = @client.request(:post, "#{phone_number_id}/messages",
234
- body: payload.to_json, response_type: :json)
235
- Types::SendMessageResponse.new(response)
236
- end
237
-
238
- # Interactive List Messages
239
- def send_interactive_list(phone_number_id:, to:, body_text:, button_text:, sections:,
240
- header: nil, footer: nil, context_message_id: nil,
241
- biz_opaque_callback_data: nil)
242
- payload = build_base_payload(
243
- phone_number_id: phone_number_id,
244
- to: to,
245
- type: 'interactive',
246
- context_message_id: context_message_id,
247
- biz_opaque_callback_data: biz_opaque_callback_data
248
- )
249
-
250
- interactive_obj = {
251
- type: 'list',
252
- body: { text: body_text },
253
- action: {
254
- button: button_text,
255
- sections: sections
256
- }
257
- }
258
-
259
- interactive_obj[:header] = header if header
260
- interactive_obj[:footer] = footer if footer
261
-
262
- payload[:interactive] = interactive_obj
263
-
264
- response = @client.request(:post, "#{phone_number_id}/messages",
265
- body: payload.to_json, response_type: :json)
266
- Types::SendMessageResponse.new(response)
267
- end
268
-
269
- # Mark Message as Read
270
- def mark_read(phone_number_id:, message_id:)
271
- payload = {
272
- messaging_product: 'whatsapp',
273
- status: 'read',
274
- message_id: message_id
275
- }
276
-
277
- response = @client.request(:post, "#{phone_number_id}/messages",
278
- body: payload.to_json, response_type: :json)
279
- Types::GraphSuccessResponse.new(response)
280
- end
281
-
282
- # Send Typing Indicator
283
- def send_typing_indicator(phone_number_id:, to:)
284
- payload = {
285
- messaging_product: 'whatsapp',
286
- recipient_type: 'individual',
287
- to: to,
288
- type: 'text',
289
- text: { typing_indicator: { type: 'text' } }
290
- }
291
-
292
- response = @client.request(:post, "#{phone_number_id}/messages",
293
- body: payload.to_json, response_type: :json)
294
- Types::GraphSuccessResponse.new(response)
295
- end
296
-
297
- # Query Message History (Kapso Proxy only)
298
- def query(phone_number_id:, direction: nil, status: nil, since: nil, until_time: nil,
299
- conversation_id: nil, limit: nil, after: nil, before: nil, fields: nil)
300
- assert_kapso_proxy('Message history API')
301
-
302
- query_params = {
303
- phone_number_id: phone_number_id,
304
- direction: direction,
305
- status: status,
306
- since: since,
307
- until: until_time,
308
- conversation_id: conversation_id,
309
- limit: limit,
310
- after: after,
311
- before: before,
312
- fields: fields
313
- }.compact
314
-
315
- response = @client.request(:get, "#{phone_number_id}/messages",
316
- query: query_params, response_type: :json)
317
- Types::PagedResponse.new(response)
318
- end
319
-
320
- # List Messages by Conversation (Kapso Proxy only)
321
- def list_by_conversation(phone_number_id:, conversation_id:, limit: nil,
322
- after: nil, before: nil, fields: nil)
323
- query(
324
- phone_number_id: phone_number_id,
325
- conversation_id: conversation_id,
326
- limit: limit,
327
- after: after,
328
- before: before,
329
- fields: fields
330
- )
331
- end
332
-
333
- private
334
-
335
- def build_base_payload(phone_number_id:, to:, type:, context_message_id: nil,
336
- biz_opaque_callback_data: nil)
337
- payload = {
338
- messaging_product: 'whatsapp',
339
- recipient_type: 'individual',
340
- to: to,
341
- type: type
342
- }
343
-
344
- if context_message_id
345
- payload[:context] = { message_id: context_message_id }
346
- end
347
-
348
- if biz_opaque_callback_data
349
- payload[:biz_opaque_callback_data] = biz_opaque_callback_data
350
- end
351
-
352
- payload
353
- end
354
-
355
- def build_media_object(media, caption = nil)
356
- media_obj = case media
357
- when Hash
358
- media.dup
359
- when String
360
- # Assume it's either a media ID or URL
361
- if media.match?(/\A\w+\z/) # Simple alphanumeric ID
362
- { id: media }
363
- else
364
- { link: media }
365
- end
366
- else
367
- raise ArgumentError, 'Media must be a Hash, media ID string, or URL string'
368
- end
369
-
370
- media_obj[:caption] = caption if caption
371
- media_obj
372
- end
373
-
374
- def assert_kapso_proxy(feature)
375
- unless @client.kapso_proxy?
376
- raise Errors::KapsoProxyRequiredError.new(feature)
377
- end
378
- end
379
- end
380
- end
1
+ # frozen_string_literal: true
2
+
3
+ module KapsoClientRuby
4
+ module Resources
5
+ class Messages
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Text Messages
11
+ # @param recipient_type [String] 'individual' or 'group' (default: 'individual')
12
+ def send_text(phone_number_id:, to:, body:, preview_url: nil, recipient_type: 'individual',
13
+ context_message_id: nil, biz_opaque_callback_data: nil)
14
+ payload = build_base_payload(
15
+ phone_number_id: phone_number_id,
16
+ to: to,
17
+ type: 'text',
18
+ recipient_type: recipient_type,
19
+ context_message_id: context_message_id,
20
+ biz_opaque_callback_data: biz_opaque_callback_data
21
+ )
22
+
23
+ payload[:text] = { body: body }
24
+ payload[:text][:preview_url] = preview_url unless preview_url.nil?
25
+
26
+ response = @client.request(:post, "#{phone_number_id}/messages",
27
+ body: payload.to_json, response_type: :json)
28
+ Types::SendMessageResponse.new(response)
29
+ end
30
+
31
+ # Image Messages
32
+ # @param recipient_type [String] 'individual' or 'group' (default: 'individual')
33
+ def send_image(phone_number_id:, to:, image:, caption: nil, recipient_type: 'individual',
34
+ context_message_id: nil, biz_opaque_callback_data: nil)
35
+ payload = build_base_payload(
36
+ phone_number_id: phone_number_id,
37
+ to: to,
38
+ type: 'image',
39
+ recipient_type: recipient_type,
40
+ context_message_id: context_message_id,
41
+ biz_opaque_callback_data: biz_opaque_callback_data
42
+ )
43
+
44
+ image_obj = build_media_object(image, caption)
45
+ payload[:image] = image_obj
46
+
47
+ response = @client.request(:post, "#{phone_number_id}/messages",
48
+ body: payload.to_json, response_type: :json)
49
+ Types::SendMessageResponse.new(response)
50
+ end
51
+
52
+ # Audio Messages
53
+ # @param phone_number_id [String] Phone number ID
54
+ # @param to [String] Recipient WhatsApp ID
55
+ # @param audio [Hash, String] Audio media (id or link)
56
+ # @param voice [Boolean] Set true for voice notes (OGG/OPUS format)
57
+ def send_audio(phone_number_id:, to:, audio:, voice: false,
58
+ context_message_id: nil, biz_opaque_callback_data: nil)
59
+ payload = build_base_payload(
60
+ phone_number_id: phone_number_id,
61
+ to: to,
62
+ type: 'audio',
63
+ context_message_id: context_message_id,
64
+ biz_opaque_callback_data: biz_opaque_callback_data
65
+ )
66
+
67
+ audio_obj = build_media_object(audio)
68
+
69
+ # Add voice flag for voice notes (OGG/OPUS format recommended)
70
+ if voice
71
+ audio_obj[:voice] = true
72
+ end
73
+
74
+ payload[:audio] = audio_obj
75
+
76
+ response = @client.request(:post, "#{phone_number_id}/messages",
77
+ body: payload.to_json, response_type: :json)
78
+ Types::SendMessageResponse.new(response)
79
+ end
80
+
81
+ # Document Messages
82
+ def send_document(phone_number_id:, to:, document:, caption: nil, filename: nil,
83
+ context_message_id: nil, biz_opaque_callback_data: nil)
84
+ payload = build_base_payload(
85
+ phone_number_id: phone_number_id,
86
+ to: to,
87
+ type: 'document',
88
+ context_message_id: context_message_id,
89
+ biz_opaque_callback_data: biz_opaque_callback_data
90
+ )
91
+
92
+ document_obj = build_media_object(document, caption)
93
+ document_obj[:filename] = filename if filename
94
+ payload[:document] = document_obj
95
+
96
+ response = @client.request(:post, "#{phone_number_id}/messages",
97
+ body: payload.to_json, response_type: :json)
98
+ Types::SendMessageResponse.new(response)
99
+ end
100
+
101
+ # Video Messages
102
+ # @param recipient_type [String] 'individual' or 'group' (default: 'individual')
103
+ def send_video(phone_number_id:, to:, video:, caption: nil, recipient_type: 'individual',
104
+ context_message_id: nil, biz_opaque_callback_data: nil)
105
+ payload = build_base_payload(
106
+ phone_number_id: phone_number_id,
107
+ to: to,
108
+ type: 'video',
109
+ recipient_type: recipient_type,
110
+ context_message_id: context_message_id,
111
+ biz_opaque_callback_data: biz_opaque_callback_data
112
+ )
113
+
114
+ payload[:video] = build_media_object(video, caption)
115
+
116
+ response = @client.request(:post, "#{phone_number_id}/messages",
117
+ body: payload.to_json, response_type: :json)
118
+ Types::SendMessageResponse.new(response)
119
+ end
120
+
121
+ # Sticker Messages
122
+ def send_sticker(phone_number_id:, to:, sticker:, context_message_id: nil,
123
+ biz_opaque_callback_data: nil)
124
+ payload = build_base_payload(
125
+ phone_number_id: phone_number_id,
126
+ to: to,
127
+ type: 'sticker',
128
+ context_message_id: context_message_id,
129
+ biz_opaque_callback_data: biz_opaque_callback_data
130
+ )
131
+
132
+ payload[:sticker] = build_media_object(sticker)
133
+
134
+ response = @client.request(:post, "#{phone_number_id}/messages",
135
+ body: payload.to_json, response_type: :json)
136
+ Types::SendMessageResponse.new(response)
137
+ end
138
+
139
+ # Location Messages
140
+ def send_location(phone_number_id:, to:, latitude:, longitude:, name: nil,
141
+ address: nil, context_message_id: nil, biz_opaque_callback_data: nil)
142
+ payload = build_base_payload(
143
+ phone_number_id: phone_number_id,
144
+ to: to,
145
+ type: 'location',
146
+ context_message_id: context_message_id,
147
+ biz_opaque_callback_data: biz_opaque_callback_data
148
+ )
149
+
150
+ location_obj = {
151
+ latitude: latitude,
152
+ longitude: longitude
153
+ }
154
+ location_obj[:name] = name if name
155
+ location_obj[:address] = address if address
156
+
157
+ payload[:location] = location_obj
158
+
159
+ response = @client.request(:post, "#{phone_number_id}/messages",
160
+ body: payload.to_json, response_type: :json)
161
+ Types::SendMessageResponse.new(response)
162
+ end
163
+
164
+ # Contact Messages
165
+ def send_contacts(phone_number_id:, to:, contacts:, context_message_id: nil,
166
+ biz_opaque_callback_data: nil)
167
+ payload = build_base_payload(
168
+ phone_number_id: phone_number_id,
169
+ to: to,
170
+ type: 'contacts',
171
+ context_message_id: context_message_id,
172
+ biz_opaque_callback_data: biz_opaque_callback_data
173
+ )
174
+
175
+ payload[:contacts] = contacts
176
+
177
+ response = @client.request(:post, "#{phone_number_id}/messages",
178
+ body: payload.to_json, response_type: :json)
179
+ Types::SendMessageResponse.new(response)
180
+ end
181
+
182
+ # Template Messages
183
+ def send_template(phone_number_id:, to:, name:, language:, components: nil,
184
+ context_message_id: nil, biz_opaque_callback_data: nil)
185
+ payload = build_base_payload(
186
+ phone_number_id: phone_number_id,
187
+ to: to,
188
+ type: 'template',
189
+ context_message_id: context_message_id,
190
+ biz_opaque_callback_data: biz_opaque_callback_data
191
+ )
192
+
193
+ template_obj = {
194
+ name: name,
195
+ language: { code: language }
196
+ }
197
+ template_obj[:components] = components if components
198
+
199
+ payload[:template] = template_obj
200
+
201
+ response = @client.request(:post, "#{phone_number_id}/messages",
202
+ body: payload.to_json, response_type: :json)
203
+ Types::SendMessageResponse.new(response)
204
+ end
205
+
206
+ # Reaction Messages
207
+ def send_reaction(phone_number_id:, to:, message_id:, emoji: nil,
208
+ context_message_id: nil, biz_opaque_callback_data: nil)
209
+ payload = build_base_payload(
210
+ phone_number_id: phone_number_id,
211
+ to: to,
212
+ type: 'reaction',
213
+ context_message_id: context_message_id,
214
+ biz_opaque_callback_data: biz_opaque_callback_data
215
+ )
216
+
217
+ reaction_obj = { message_id: message_id }
218
+ reaction_obj[:emoji] = emoji if emoji # nil emoji removes reaction
219
+
220
+ payload[:reaction] = reaction_obj
221
+
222
+ response = @client.request(:post, "#{phone_number_id}/messages",
223
+ body: payload.to_json, response_type: :json)
224
+ Types::SendMessageResponse.new(response)
225
+ end
226
+
227
+ # Interactive Button Messages
228
+ # @param phone_number_id [String] Phone number ID
229
+ # @param to [String] Recipient WhatsApp ID
230
+ # @param body_text [String] Message body text
231
+ # @param buttons [Array<Hash>] Array of button objects (max 3)
232
+ # @param header [Hash, nil] Optional header (text, image, video, or document)
233
+ # @param footer [Hash, String, nil] Optional footer text or object
234
+ def send_interactive_buttons(phone_number_id:, to:, body_text:, buttons:,
235
+ header: nil, footer: nil, context_message_id: nil,
236
+ biz_opaque_callback_data: nil)
237
+ # Validate button count (max 3 buttons)
238
+ if buttons.length > 3
239
+ raise ArgumentError, "Maximum 3 buttons allowed (current: #{buttons.length})"
240
+ end
241
+
242
+ if buttons.empty?
243
+ raise ArgumentError, 'At least 1 button is required'
244
+ end
245
+
246
+ # Validate header if provided (now supports text, image, video, document)
247
+ if header
248
+ validate_interactive_header(header, 'button')
249
+ end
250
+
251
+ payload = build_base_payload(
252
+ phone_number_id: phone_number_id,
253
+ to: to,
254
+ type: 'interactive',
255
+ context_message_id: context_message_id,
256
+ biz_opaque_callback_data: biz_opaque_callback_data
257
+ )
258
+
259
+ interactive_obj = {
260
+ type: 'button',
261
+ body: { text: body_text },
262
+ action: { buttons: buttons }
263
+ }
264
+
265
+ # Add header (supports text and media types)
266
+ interactive_obj[:header] = header if header
267
+
268
+ # Add footer (handle both string and hash formats)
269
+ if footer
270
+ interactive_obj[:footer] = footer.is_a?(String) ? { text: footer } : footer
271
+ end
272
+
273
+ payload[:interactive] = interactive_obj
274
+
275
+ response = @client.request(:post, "#{phone_number_id}/messages",
276
+ body: payload.to_json, response_type: :json)
277
+ Types::SendMessageResponse.new(response)
278
+ end
279
+
280
+ # Interactive List Messages
281
+ # @param phone_number_id [String] Phone number ID
282
+ # @param to [String] Recipient WhatsApp ID
283
+ # @param body_text [String] Message body text (max 4096 characters)
284
+ # @param button_text [String] Button text (list trigger)
285
+ # @param sections [Array<Hash>] List sections (max 10 rows total)
286
+ # @param header [Hash, nil] Optional text header only
287
+ # @param footer [Hash, String, nil] Optional footer text or object
288
+ def send_interactive_list(phone_number_id:, to:, body_text:, button_text:, sections:,
289
+ header: nil, footer: nil, context_message_id: nil,
290
+ biz_opaque_callback_data: nil)
291
+ # Validate body text length (updated to 4096)
292
+ if body_text.length > 4096
293
+ raise ArgumentError, "Body text max 4096 characters (current: #{body_text.length})"
294
+ end
295
+
296
+ # Validate total row count (max 10 across all sections)
297
+ total_rows = sections.sum do |section|
298
+ rows = section[:rows] || section['rows'] || []
299
+ rows.length
300
+ end
301
+
302
+ if total_rows > 10
303
+ raise ArgumentError, "Maximum 10 rows total across all sections (current: #{total_rows})"
304
+ end
305
+
306
+ if total_rows == 0
307
+ raise ArgumentError, 'At least 1 row is required'
308
+ end
309
+
310
+ # Header for lists must be text type only
311
+ if header
312
+ header_type = header[:type] || header['type']
313
+ unless header_type.nil? || header_type.to_s == 'text'
314
+ raise ArgumentError, "List messages only support text headers (received: #{header_type})"
315
+ end
316
+ validate_text_header(header) if header_type
317
+ end
318
+
319
+ payload = build_base_payload(
320
+ phone_number_id: phone_number_id,
321
+ to: to,
322
+ type: 'interactive',
323
+ context_message_id: context_message_id,
324
+ biz_opaque_callback_data: biz_opaque_callback_data
325
+ )
326
+
327
+ interactive_obj = {
328
+ type: 'list',
329
+ body: { text: body_text },
330
+ action: {
331
+ button: button_text,
332
+ sections: sections
333
+ }
334
+ }
335
+
336
+ interactive_obj[:header] = header if header
337
+
338
+ # Add footer (handle both string and hash formats)
339
+ if footer
340
+ interactive_obj[:footer] = footer.is_a?(String) ? { text: footer } : footer
341
+ end
342
+
343
+ payload[:interactive] = interactive_obj
344
+
345
+ response = @client.request(:post, "#{phone_number_id}/messages",
346
+ body: payload.to_json, response_type: :json)
347
+ Types::SendMessageResponse.new(response)
348
+ end
349
+
350
+ # Send Flow Message
351
+ def send_flow(phone_number_id:, to:, flow_id:, flow_cta:, flow_token:,
352
+ screen: nil, flow_action: 'navigate', mode: 'published',
353
+ flow_action_payload: nil, header: nil, body_text: nil,
354
+ footer_text: nil, context_message_id: nil,
355
+ biz_opaque_callback_data: nil)
356
+ payload = build_base_payload(
357
+ phone_number_id: phone_number_id,
358
+ to: to,
359
+ type: 'interactive',
360
+ context_message_id: context_message_id,
361
+ biz_opaque_callback_data: biz_opaque_callback_data
362
+ )
363
+
364
+ # Build Flow action parameters
365
+ action_params = {
366
+ flow_message_version: '3',
367
+ flow_token: flow_token,
368
+ flow_id: flow_id,
369
+ flow_cta: flow_cta,
370
+ flow_action: flow_action,
371
+ mode: mode
372
+ }
373
+
374
+ # Add optional parameters
375
+ action_params[:flow_action_payload] = flow_action_payload if flow_action_payload
376
+
377
+ # Add screen parameter for navigate action
378
+ if flow_action == 'navigate' && screen
379
+ action_params[:flow_action_payload] ||= {}
380
+ action_params[:flow_action_payload][:screen] = screen
381
+ end
382
+
383
+ interactive_obj = {
384
+ type: 'flow',
385
+ action: action_params
386
+ }
387
+
388
+ # Add optional header and body
389
+ interactive_obj[:header] = header if header
390
+ interactive_obj[:body] = { text: body_text } if body_text
391
+ interactive_obj[:footer] = { text: footer_text } if footer_text
392
+
393
+ payload[:interactive] = interactive_obj
394
+
395
+ response = @client.request(:post, "#{phone_number_id}/messages",
396
+ body: payload.to_json, response_type: :json)
397
+ Types::SendMessageResponse.new(response)
398
+ end
399
+
400
+ # Send Interactive CTA URL Message
401
+ # @param phone_number_id [String] Phone number ID
402
+ # @param to [String] Recipient WhatsApp ID
403
+ # @param body_text [String] Message body text (max 1024 characters)
404
+ # @param display_text [String] Button display text (max 20 characters)
405
+ # @param url [String] Target URL (must be HTTPS)
406
+ # @param header [Hash, nil] Optional header (text, image, video, or document)
407
+ # @param footer_text [String, nil] Optional footer text (max 60 characters)
408
+ def send_interactive_cta_url(phone_number_id:, to:, body_text:, display_text:, url:,
409
+ header: nil, footer_text: nil, context_message_id: nil,
410
+ biz_opaque_callback_data: nil)
411
+ # Validate parameters
412
+ validate_cta_url_params(body_text, display_text, url, footer_text)
413
+
414
+ payload = build_base_payload(
415
+ phone_number_id: phone_number_id,
416
+ to: to,
417
+ type: 'interactive',
418
+ context_message_id: context_message_id,
419
+ biz_opaque_callback_data: biz_opaque_callback_data
420
+ )
421
+
422
+ interactive_obj = {
423
+ type: 'cta_url',
424
+ body: { text: body_text },
425
+ action: {
426
+ name: 'cta_url',
427
+ parameters: {
428
+ display_text: display_text,
429
+ url: url
430
+ }
431
+ }
432
+ }
433
+
434
+ # Add optional header (supports text, image, video, document)
435
+ if header
436
+ validate_interactive_header(header, 'cta_url')
437
+ interactive_obj[:header] = header
438
+ end
439
+
440
+ # Add optional footer
441
+ interactive_obj[:footer] = { text: footer_text } if footer_text
442
+
443
+ payload[:interactive] = interactive_obj
444
+
445
+ response = @client.request(:post, "#{phone_number_id}/messages",
446
+ body: payload.to_json, response_type: :json)
447
+ Types::SendMessageResponse.new(response)
448
+ end
449
+
450
+ # Send Interactive Catalog Message
451
+ # @param phone_number_id [String] Phone number ID
452
+ # @param to [String] Recipient WhatsApp ID
453
+ # @param body_text [String] Message body text (max 1024 characters)
454
+ # @param thumbnail_product_retailer_id [String] Product retailer ID for thumbnail
455
+ # @param footer_text [String, nil] Optional footer text (max 60 characters)
456
+ def send_interactive_catalog_message(phone_number_id:, to:, body_text:,
457
+ thumbnail_product_retailer_id:,
458
+ footer_text: nil, context_message_id: nil,
459
+ biz_opaque_callback_data: nil)
460
+ # Validate parameters
461
+ validate_catalog_message_params(body_text, thumbnail_product_retailer_id, footer_text)
462
+
463
+ payload = build_base_payload(
464
+ phone_number_id: phone_number_id,
465
+ to: to,
466
+ type: 'interactive',
467
+ context_message_id: context_message_id,
468
+ biz_opaque_callback_data: biz_opaque_callback_data
469
+ )
470
+
471
+ interactive_obj = {
472
+ type: 'catalog_message',
473
+ body: { text: body_text },
474
+ action: {
475
+ name: 'catalog_message',
476
+ parameters: {
477
+ thumbnail_product_retailer_id: thumbnail_product_retailer_id
478
+ }
479
+ }
480
+ }
481
+
482
+ # Add optional footer
483
+ interactive_obj[:footer] = { text: footer_text } if footer_text
484
+
485
+ payload[:interactive] = interactive_obj
486
+
487
+ response = @client.request(:post, "#{phone_number_id}/messages",
488
+ body: payload.to_json, response_type: :json)
489
+ Types::SendMessageResponse.new(response)
490
+ end
491
+
492
+ # Send Interactive Location Request
493
+ # @param phone_number_id [String] Phone number ID
494
+ # @param to [String] Recipient WhatsApp ID
495
+ # @param body_text [String] Message body text
496
+ # @param header [Hash, nil] Optional header (text, image, video, or document)
497
+ # @param footer_text [String, nil] Optional footer text
498
+ def send_interactive_location_request(phone_number_id:, to:, body_text:,
499
+ header: nil, footer_text: nil,
500
+ context_message_id: nil,
501
+ biz_opaque_callback_data: nil)
502
+ payload = build_base_payload(
503
+ phone_number_id: phone_number_id,
504
+ to: to,
505
+ type: 'interactive',
506
+ context_message_id: context_message_id,
507
+ biz_opaque_callback_data: biz_opaque_callback_data
508
+ )
509
+
510
+ interactive_obj = {
511
+ type: 'location_request_message',
512
+ body: { text: body_text },
513
+ action: {
514
+ name: 'send_location'
515
+ }
516
+ }
517
+
518
+ # Add optional header (supports text, image, video, document)
519
+ if header
520
+ validate_interactive_header(header, 'location_request')
521
+ interactive_obj[:header] = header
522
+ end
523
+
524
+ # Add optional footer
525
+ interactive_obj[:footer] = { text: footer_text } if footer_text
526
+
527
+ payload[:interactive] = interactive_obj
528
+
529
+ response = @client.request(:post, "#{phone_number_id}/messages",
530
+ body: payload.to_json, response_type: :json)
531
+ Types::SendMessageResponse.new(response)
532
+ end
533
+
534
+ # Mark Message as Read
535
+ def mark_read(phone_number_id:, message_id:)
536
+ payload = {
537
+ messaging_product: 'whatsapp',
538
+ status: 'read',
539
+ message_id: message_id
540
+ }
541
+
542
+ response = @client.request(:post, "#{phone_number_id}/messages",
543
+ body: payload.to_json, response_type: :json)
544
+ Types::GraphSuccessResponse.new(response)
545
+ end
546
+
547
+ # Send Typing Indicator
548
+ def send_typing_indicator(phone_number_id:, to:)
549
+ payload = {
550
+ messaging_product: 'whatsapp',
551
+ recipient_type: 'individual',
552
+ to: to,
553
+ type: 'text',
554
+ text: { typing_indicator: { type: 'text' } }
555
+ }
556
+
557
+ response = @client.request(:post, "#{phone_number_id}/messages",
558
+ body: payload.to_json, response_type: :json)
559
+ Types::GraphSuccessResponse.new(response)
560
+ end
561
+
562
+ # Query Message History (Kapso Proxy only)
563
+ def query(phone_number_id:, direction: nil, status: nil, since: nil, until_time: nil,
564
+ conversation_id: nil, limit: nil, after: nil, before: nil, fields: nil)
565
+ assert_kapso_proxy('Message history API')
566
+
567
+ query_params = {
568
+ phone_number_id: phone_number_id,
569
+ direction: direction,
570
+ status: status,
571
+ since: since,
572
+ until: until_time,
573
+ conversation_id: conversation_id,
574
+ limit: limit,
575
+ after: after,
576
+ before: before,
577
+ fields: fields
578
+ }.compact
579
+
580
+ response = @client.request(:get, "#{phone_number_id}/messages",
581
+ query: query_params, response_type: :json)
582
+ Types::PagedResponse.new(response)
583
+ end
584
+
585
+ # List Messages by Conversation (Kapso Proxy only)
586
+ def list_by_conversation(phone_number_id:, conversation_id:, limit: nil,
587
+ after: nil, before: nil, fields: nil)
588
+ query(
589
+ phone_number_id: phone_number_id,
590
+ conversation_id: conversation_id,
591
+ limit: limit,
592
+ after: after,
593
+ before: before,
594
+ fields: fields
595
+ )
596
+ end
597
+
598
+ private
599
+
600
+ def build_base_payload(phone_number_id:, to:, type:, recipient_type: 'individual',
601
+ context_message_id: nil, biz_opaque_callback_data: nil)
602
+ # Validate recipient_type
603
+ valid_types = ['individual', 'group']
604
+ unless valid_types.include?(recipient_type)
605
+ raise ArgumentError, "recipient_type must be 'individual' or 'group' (received: #{recipient_type})"
606
+ end
607
+
608
+ payload = {
609
+ messaging_product: 'whatsapp',
610
+ recipient_type: recipient_type,
611
+ to: to,
612
+ type: type
613
+ }
614
+
615
+ if context_message_id
616
+ payload[:context] = { message_id: context_message_id }
617
+ end
618
+
619
+ if biz_opaque_callback_data
620
+ payload[:biz_opaque_callback_data] = biz_opaque_callback_data
621
+ end
622
+
623
+ payload
624
+ end
625
+
626
+ def build_media_object(media, caption = nil)
627
+ media_obj = case media
628
+ when Hash
629
+ media.dup
630
+ when String
631
+ # Assume it's either a media ID or URL
632
+ if media.match?(/\A\w+\z/) # Simple alphanumeric ID
633
+ { id: media }
634
+ else
635
+ { link: media }
636
+ end
637
+ else
638
+ raise ArgumentError, 'Media must be a Hash, media ID string, or URL string'
639
+ end
640
+
641
+ media_obj[:caption] = caption if caption
642
+ media_obj
643
+ end
644
+
645
+ def assert_kapso_proxy(feature)
646
+ unless @client.kapso_proxy?
647
+ raise Errors::KapsoProxyRequiredError.new(feature)
648
+ end
649
+ end
650
+
651
+ # Validate CTA URL parameters
652
+ def validate_cta_url_params(body_text, display_text, url, footer_text)
653
+ # Body text validation
654
+ if body_text.nil? || body_text.strip.empty?
655
+ raise ArgumentError, 'body_text is required'
656
+ end
657
+
658
+ if body_text.length > 1024
659
+ raise ArgumentError, "body_text max 1024 characters (current: #{body_text.length})"
660
+ end
661
+
662
+ # Display text validation
663
+ if display_text.nil? || display_text.strip.empty?
664
+ raise ArgumentError, 'display_text is required'
665
+ end
666
+
667
+ if display_text.length > 20
668
+ raise ArgumentError, "display_text max 20 characters (current: #{display_text.length})"
669
+ end
670
+
671
+ # URL validation
672
+ if url.nil? || url.strip.empty?
673
+ raise ArgumentError, 'url is required'
674
+ end
675
+
676
+ unless url.match?(%r{\Ahttps?://}i)
677
+ raise ArgumentError, 'url must start with http:// or https://'
678
+ end
679
+
680
+ # Footer text validation
681
+ if footer_text && footer_text.length > 60
682
+ raise ArgumentError, "footer_text max 60 characters (current: #{footer_text.length})"
683
+ end
684
+ end
685
+
686
+ # Validate catalog message parameters
687
+ def validate_catalog_message_params(body_text, thumbnail_product_retailer_id, footer_text)
688
+ # Body text validation
689
+ if body_text.nil? || body_text.strip.empty?
690
+ raise ArgumentError, 'body_text is required'
691
+ end
692
+
693
+ if body_text.length > 1024
694
+ raise ArgumentError, "body_text max 1024 characters (current: #{body_text.length})"
695
+ end
696
+
697
+ # Thumbnail product ID validation
698
+ if thumbnail_product_retailer_id.nil? || thumbnail_product_retailer_id.to_s.strip.empty?
699
+ raise ArgumentError, 'thumbnail_product_retailer_id is required'
700
+ end
701
+
702
+ # Footer text validation
703
+ if footer_text && footer_text.length > 60
704
+ raise ArgumentError, "footer_text max 60 characters (current: #{footer_text.length})"
705
+ end
706
+ end
707
+
708
+ # Validate interactive message header
709
+ def validate_interactive_header(header, message_type = 'interactive')
710
+ valid_types = ['text', 'image', 'video', 'document']
711
+ header_type = header[:type] || header['type']
712
+
713
+ if header_type.nil?
714
+ raise ArgumentError, 'Header must have a type field'
715
+ end
716
+
717
+ unless valid_types.include?(header_type.to_s)
718
+ raise ArgumentError, "Invalid header type '#{header_type}'. Must be one of: #{valid_types.join(', ')}"
719
+ end
720
+
721
+ case header_type.to_s
722
+ when 'text'
723
+ validate_text_header(header)
724
+ when 'image', 'video', 'document'
725
+ validate_media_header(header)
726
+ end
727
+ end
728
+
729
+ # Validate text header
730
+ def validate_text_header(header)
731
+ text = header[:text] || header['text']
732
+
733
+ if text.nil? || text.strip.empty?
734
+ raise ArgumentError, 'Text header requires text field'
735
+ end
736
+
737
+ if text.length > 60
738
+ raise ArgumentError, "Header text max 60 characters (current: #{text.length})"
739
+ end
740
+ end
741
+
742
+ # Validate media header (image, video, document)
743
+ def validate_media_header(header)
744
+ header_type = (header[:type] || header['type']).to_s
745
+ media = header[header_type.to_sym] || header[header_type]
746
+
747
+ if media.nil?
748
+ raise ArgumentError, "#{header_type.capitalize} header requires #{header_type} field"
749
+ end
750
+
751
+ # Media must have id or link
752
+ has_id = media[:id] || media['id']
753
+ has_link = media[:link] || media['link']
754
+
755
+ unless has_id || has_link
756
+ raise ArgumentError, "#{header_type.capitalize} must have 'id' or 'link'"
757
+ end
758
+ end
759
+ end
760
+ end
381
761
  end