whatsapp-cloud-api-ruby 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
4
+ module Resources
5
+ class Conversations
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # List conversations (Kapso Proxy only)
11
+ def list(phone_number_id:, status: nil, last_active_since: nil,
12
+ last_active_until: nil, phone_number: nil, limit: nil,
13
+ after: nil, before: nil, fields: nil)
14
+ assert_kapso_proxy('Conversations API')
15
+
16
+ query_params = {
17
+ status: status,
18
+ last_active_since: last_active_since,
19
+ last_active_until: last_active_until,
20
+ phone_number: phone_number,
21
+ limit: limit,
22
+ after: after,
23
+ before: before,
24
+ fields: fields
25
+ }.compact
26
+
27
+ response = @client.request(:get, "#{phone_number_id}/conversations",
28
+ query: query_params, response_type: :json)
29
+ Types::PagedResponse.new(response, Types::ConversationRecord)
30
+ end
31
+
32
+ # Get conversation details (Kapso Proxy only)
33
+ def get(conversation_id:)
34
+ assert_kapso_proxy('Conversations API')
35
+
36
+ raise ArgumentError, 'conversation_id cannot be empty' if conversation_id.nil? || conversation_id.strip.empty?
37
+
38
+ response = @client.request(:get, "conversations/#{conversation_id}",
39
+ response_type: :json)
40
+
41
+ # Handle both single object and data envelope responses
42
+ if response.is_a?(Hash) && response.key?('data')
43
+ Types::ConversationRecord.new(response['data'])
44
+ else
45
+ Types::ConversationRecord.new(response)
46
+ end
47
+ end
48
+
49
+ # Update conversation status (Kapso Proxy only)
50
+ def update_status(conversation_id:, status:)
51
+ assert_kapso_proxy('Conversations API')
52
+
53
+ raise ArgumentError, 'conversation_id cannot be empty' if conversation_id.nil? || conversation_id.strip.empty?
54
+ raise ArgumentError, 'status cannot be empty' if status.nil? || status.strip.empty?
55
+
56
+ payload = { status: status }
57
+
58
+ response = @client.request(:patch, "conversations/#{conversation_id}",
59
+ body: payload.to_json, response_type: :json)
60
+ Types::GraphSuccessResponse.new(response)
61
+ end
62
+
63
+ # Archive conversation (Kapso Proxy only)
64
+ def archive(conversation_id:)
65
+ update_status(conversation_id: conversation_id, status: 'archived')
66
+ end
67
+
68
+ # Unarchive conversation (Kapso Proxy only)
69
+ def unarchive(conversation_id:)
70
+ update_status(conversation_id: conversation_id, status: 'active')
71
+ end
72
+
73
+ # End conversation (Kapso Proxy only)
74
+ def end_conversation(conversation_id:)
75
+ update_status(conversation_id: conversation_id, status: 'ended')
76
+ end
77
+
78
+ # Get conversation analytics (Kapso Proxy only)
79
+ def analytics(phone_number_id:, conversation_id: nil, since: nil,
80
+ until_time: nil, granularity: 'day')
81
+ assert_kapso_proxy('Conversation Analytics API')
82
+
83
+ query_params = {
84
+ conversation_id: conversation_id,
85
+ since: since,
86
+ until: until_time,
87
+ granularity: granularity
88
+ }.compact
89
+
90
+ response = @client.request(:get, "#{phone_number_id}/conversations/analytics",
91
+ query: query_params, response_type: :json)
92
+ response
93
+ end
94
+
95
+ private
96
+
97
+ def assert_kapso_proxy(feature)
98
+ unless @client.kapso_proxy?
99
+ raise Errors::KapsoProxyRequiredError.new(feature)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mime/types'
4
+
5
+ module WhatsAppCloudApi
6
+ module Resources
7
+ class Media
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Upload media file
13
+ def upload(phone_number_id:, type:, file:, filename: nil, messaging_product: 'whatsapp',
14
+ upload_strategy: nil)
15
+ validate_media_type(type)
16
+
17
+ # Build multipart form data
18
+ form_data = {
19
+ 'messaging_product' => messaging_product,
20
+ 'type' => type
21
+ }
22
+
23
+ # Handle file parameter - can be File, IO, or file path string
24
+ file_obj = case file
25
+ when String
26
+ # Assume it's a file path
27
+ File.open(file, 'rb')
28
+ when File, IO, StringIO
29
+ file
30
+ else
31
+ raise ArgumentError, 'file must be a File, IO object, or file path string'
32
+ end
33
+
34
+ # Determine filename and content type
35
+ if filename.nil? && file.is_a?(String)
36
+ filename = File.basename(file)
37
+ end
38
+
39
+ content_type = determine_content_type(file_obj, filename, type)
40
+
41
+ form_data['file'] = Faraday::UploadIO.new(file_obj, content_type, filename)
42
+ form_data['upload_strategy'] = upload_strategy if upload_strategy
43
+
44
+ # Set multipart content type header
45
+ headers = { 'Content-Type' => 'multipart/form-data' }
46
+
47
+ response = @client.request(:post, "#{phone_number_id}/media",
48
+ body: form_data, headers: headers, response_type: :json)
49
+
50
+ # Close file if we opened it
51
+ file_obj.close if file.is_a?(String) && file_obj.respond_to?(:close)
52
+
53
+ Types::MediaUploadResponse.new(response)
54
+ end
55
+
56
+ # Get media metadata
57
+ def get(media_id:, phone_number_id: nil)
58
+ # phone_number_id is required for Kapso proxy
59
+ if @client.kapso_proxy? && phone_number_id.nil?
60
+ raise ArgumentError, 'phone_number_id is required when using Kapso proxy'
61
+ end
62
+
63
+ query_params = {}
64
+ query_params[:phone_number_id] = phone_number_id if phone_number_id
65
+
66
+ response = @client.request(:get, media_id,
67
+ query: query_params, response_type: :json)
68
+ Types::MediaMetadataResponse.new(response)
69
+ end
70
+
71
+ # Delete media
72
+ def delete(media_id:, phone_number_id: nil)
73
+ # phone_number_id is required for Kapso proxy
74
+ if @client.kapso_proxy? && phone_number_id.nil?
75
+ raise ArgumentError, 'phone_number_id is required when using Kapso proxy'
76
+ end
77
+
78
+ query_params = {}
79
+ query_params[:phone_number_id] = phone_number_id if phone_number_id
80
+
81
+ response = @client.request(:delete, media_id,
82
+ query: query_params, response_type: :json)
83
+ Types::GraphSuccessResponse.new(response)
84
+ end
85
+
86
+ # Download media content
87
+ def download(media_id:, phone_number_id: nil, headers: {},
88
+ auth: :auto, as: :binary)
89
+ # First get the media metadata to get the download URL
90
+ metadata = get(media_id: media_id, phone_number_id: phone_number_id)
91
+ download_url = metadata.url
92
+
93
+ # Determine authentication strategy
94
+ use_auth = case auth
95
+ when :auto
96
+ # Auto-detect: use auth for graph.facebook.com URLs, no auth for CDNs
97
+ download_url.include?('graph.facebook.com')
98
+ when :always
99
+ true
100
+ when :never
101
+ false
102
+ else
103
+ raise ArgumentError, 'auth must be :auto, :always, or :never'
104
+ end
105
+
106
+ # Prepare headers
107
+ download_headers = headers.dup
108
+
109
+ # Make the download request
110
+ if use_auth
111
+ response = @client.fetch(download_url, headers: download_headers)
112
+ else
113
+ response = @client.raw_request(:get, download_url, headers: download_headers)
114
+ end
115
+
116
+ unless response.success?
117
+ raise Errors::GraphApiError.new(
118
+ message: "Failed to download media: #{response.status}",
119
+ http_status: response.status,
120
+ raw_response: response.body
121
+ )
122
+ end
123
+
124
+ # Return response based on requested format
125
+ case as
126
+ when :binary
127
+ response.body
128
+ when :response
129
+ response
130
+ when :base64
131
+ require 'base64'
132
+ Base64.strict_encode64(response.body)
133
+ else
134
+ raise ArgumentError, 'as must be :binary, :response, or :base64'
135
+ end
136
+ end
137
+
138
+ # Save media to file
139
+ def save_to_file(media_id:, filepath:, phone_number_id: nil, headers: {}, auth: :auto)
140
+ content = download(
141
+ media_id: media_id,
142
+ phone_number_id: phone_number_id,
143
+ headers: headers,
144
+ auth: auth,
145
+ as: :binary
146
+ )
147
+
148
+ File.binwrite(filepath, content)
149
+ filepath
150
+ end
151
+
152
+ # Get media info including size, type, and download URL
153
+ def info(media_id:, phone_number_id: nil)
154
+ metadata = get(media_id: media_id, phone_number_id: phone_number_id)
155
+
156
+ {
157
+ id: metadata.id,
158
+ url: metadata.url,
159
+ mime_type: metadata.mime_type,
160
+ sha256: metadata.sha256,
161
+ file_size: metadata.file_size.to_i,
162
+ messaging_product: metadata.messaging_product
163
+ }
164
+ end
165
+
166
+ private
167
+
168
+ def validate_media_type(type)
169
+ valid_types = %w[image audio video document sticker]
170
+ unless valid_types.include?(type.to_s)
171
+ raise ArgumentError, "Invalid media type '#{type}'. Must be one of: #{valid_types.join(', ')}"
172
+ end
173
+ end
174
+
175
+ def determine_content_type(file_obj, filename, media_type)
176
+ # First try to determine from filename
177
+ if filename
178
+ mime_types = MIME::Types.type_for(filename)
179
+ return mime_types.first.content_type unless mime_types.empty?
180
+ end
181
+
182
+ # Try to determine from file extension if file_obj responds to path
183
+ if file_obj.respond_to?(:path) && file_obj.path
184
+ mime_types = MIME::Types.type_for(file_obj.path)
185
+ return mime_types.first.content_type unless mime_types.empty?
186
+ end
187
+
188
+ # Fall back to generic types based on media_type
189
+ case media_type.to_s
190
+ when 'image'
191
+ 'image/jpeg'
192
+ when 'audio'
193
+ 'audio/mpeg'
194
+ when 'video'
195
+ 'video/mp4'
196
+ when 'document'
197
+ 'application/pdf'
198
+ when 'sticker'
199
+ 'image/webp'
200
+ else
201
+ 'application/octet-stream'
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WhatsAppCloudApi
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
381
+ end