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.
- checksums.yaml +7 -0
- data/.rubocop.yml +82 -0
- data/CHANGELOG.md +92 -0
- data/Gemfile +21 -0
- data/README.md +735 -0
- data/Rakefile +41 -0
- data/TEMPLATE_TOOLS_GUIDE.md +121 -0
- data/WHATSAPP_24_HOUR_GUIDE.md +134 -0
- data/examples/advanced_features.rb +350 -0
- data/examples/basic_messaging.rb +137 -0
- data/examples/media_management.rb +254 -0
- data/examples/template_management.rb +391 -0
- data/lib/whatsapp_cloud_api/client.rb +317 -0
- data/lib/whatsapp_cloud_api/errors.rb +330 -0
- data/lib/whatsapp_cloud_api/resources/calls.rb +173 -0
- data/lib/whatsapp_cloud_api/resources/contacts.rb +191 -0
- data/lib/whatsapp_cloud_api/resources/conversations.rb +104 -0
- data/lib/whatsapp_cloud_api/resources/media.rb +206 -0
- data/lib/whatsapp_cloud_api/resources/messages.rb +381 -0
- data/lib/whatsapp_cloud_api/resources/phone_numbers.rb +86 -0
- data/lib/whatsapp_cloud_api/resources/templates.rb +284 -0
- data/lib/whatsapp_cloud_api/types.rb +263 -0
- data/lib/whatsapp_cloud_api/version.rb +5 -0
- data/lib/whatsapp_cloud_api.rb +69 -0
- data/scripts/.env.example +18 -0
- data/scripts/kapso_template_finder.rb +91 -0
- data/scripts/sdk_setup.rb +405 -0
- data/scripts/test.rb +60 -0
- metadata +254 -0
@@ -0,0 +1,330 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Errors
|
5
|
+
# Error categories mapped from the JavaScript implementation
|
6
|
+
ERROR_CATEGORIES = {
|
7
|
+
'authorization' => :authorization,
|
8
|
+
'permission' => :permission,
|
9
|
+
'parameter' => :parameter,
|
10
|
+
'throttling' => :throttling,
|
11
|
+
'template' => :template,
|
12
|
+
'media' => :media,
|
13
|
+
'phone_registration' => :phone_registration,
|
14
|
+
'integrity' => :integrity,
|
15
|
+
'business_eligibility' => :business_eligibility,
|
16
|
+
'reengagement_window' => :reengagement_window,
|
17
|
+
'waba_config' => :waba_config,
|
18
|
+
'flow' => :flow,
|
19
|
+
'synchronization' => :synchronization,
|
20
|
+
'server' => :server,
|
21
|
+
'unknown' => :unknown
|
22
|
+
}.freeze
|
23
|
+
|
24
|
+
# Error codes and their categories
|
25
|
+
ERROR_CODE_CATEGORIES = {
|
26
|
+
0 => :authorization,
|
27
|
+
190 => :authorization,
|
28
|
+
3 => :permission,
|
29
|
+
10 => :permission,
|
30
|
+
(200..219) => :permission,
|
31
|
+
4 => :throttling,
|
32
|
+
80007 => :throttling,
|
33
|
+
130429 => :throttling,
|
34
|
+
131048 => :throttling,
|
35
|
+
131056 => :throttling,
|
36
|
+
33 => :parameter,
|
37
|
+
100 => :parameter,
|
38
|
+
130472 => :parameter,
|
39
|
+
131008 => :parameter,
|
40
|
+
131009 => :parameter,
|
41
|
+
131021 => :parameter,
|
42
|
+
131026 => :parameter,
|
43
|
+
131051 => :media,
|
44
|
+
131052 => :media,
|
45
|
+
131053 => :media,
|
46
|
+
131000 => :server,
|
47
|
+
131016 => :server,
|
48
|
+
131057 => :server,
|
49
|
+
133004 => :server,
|
50
|
+
133005 => :server,
|
51
|
+
368 => :integrity,
|
52
|
+
130497 => :integrity,
|
53
|
+
131031 => :integrity,
|
54
|
+
131047 => :reengagement_window,
|
55
|
+
131037 => :waba_config,
|
56
|
+
131042 => :business_eligibility,
|
57
|
+
131045 => :phone_registration,
|
58
|
+
133000 => :phone_registration,
|
59
|
+
133006 => :phone_registration,
|
60
|
+
133008 => :phone_registration,
|
61
|
+
133009 => :phone_registration,
|
62
|
+
133010 => :phone_registration,
|
63
|
+
133015 => :phone_registration,
|
64
|
+
133016 => :phone_registration,
|
65
|
+
132000 => :template,
|
66
|
+
132001 => :template,
|
67
|
+
132005 => :template,
|
68
|
+
132007 => :template,
|
69
|
+
132012 => :template,
|
70
|
+
132015 => :template,
|
71
|
+
132016 => :template,
|
72
|
+
132068 => :flow,
|
73
|
+
132069 => :flow,
|
74
|
+
134011 => :business_eligibility,
|
75
|
+
135000 => :parameter,
|
76
|
+
2593107 => :synchronization,
|
77
|
+
2593108 => :synchronization
|
78
|
+
}.freeze
|
79
|
+
|
80
|
+
# Error codes that should not be retried
|
81
|
+
DO_NOT_RETRY_CODES = [131049, 131050, 131047, 368, 130497, 131031].freeze
|
82
|
+
|
83
|
+
# Error codes that require token refresh
|
84
|
+
REFRESH_TOKEN_CODES = [0, 190].freeze
|
85
|
+
|
86
|
+
class GraphApiError < StandardError
|
87
|
+
attr_reader :http_status, :code, :type, :details, :error_subcode,
|
88
|
+
:fbtrace_id, :error_data, :category, :retry_hint, :raw_response, :retry_after
|
89
|
+
|
90
|
+
def initialize(message: nil, http_status:, code: nil, type: nil, details: nil,
|
91
|
+
error_subcode: nil, fbtrace_id: nil, error_data: nil,
|
92
|
+
category: nil, retry_hint: nil, raw_response: nil, retry_after: nil)
|
93
|
+
@http_status = http_status
|
94
|
+
@code = code || http_status
|
95
|
+
@type = type || 'GraphApiError'
|
96
|
+
@details = details
|
97
|
+
@error_subcode = error_subcode
|
98
|
+
@fbtrace_id = fbtrace_id
|
99
|
+
@error_data = error_data
|
100
|
+
@retry_after = retry_after
|
101
|
+
@category = category || categorize_error_code(@code, @http_status)
|
102
|
+
@retry_hint = retry_hint || derive_retry_hint
|
103
|
+
@raw_response = raw_response
|
104
|
+
|
105
|
+
error_message = message || build_error_message
|
106
|
+
super(error_message)
|
107
|
+
end
|
108
|
+
|
109
|
+
class << self
|
110
|
+
def from_response(response, body = nil, raw_text = nil)
|
111
|
+
http_status = response.status
|
112
|
+
retry_after_ms = parse_retry_after(response.headers['retry-after'])
|
113
|
+
|
114
|
+
# Ensure body is a hash for processing
|
115
|
+
unless body.is_a?(Hash)
|
116
|
+
if body.is_a?(String)
|
117
|
+
begin
|
118
|
+
body = JSON.parse(body)
|
119
|
+
rescue JSON::ParserError
|
120
|
+
body = {}
|
121
|
+
end
|
122
|
+
else
|
123
|
+
body = {}
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Check for Graph API error envelope
|
128
|
+
if body.key?('error')
|
129
|
+
error_payload = body['error']
|
130
|
+
code = error_payload['code'] || http_status
|
131
|
+
type = error_payload['type'] || 'GraphApiError'
|
132
|
+
details = error_payload.is_a?(Hash) ? error_payload.dig('error_data', 'details') : nil
|
133
|
+
|
134
|
+
new(
|
135
|
+
message: error_payload['message'],
|
136
|
+
http_status: http_status,
|
137
|
+
code: code,
|
138
|
+
type: type,
|
139
|
+
details: details,
|
140
|
+
error_subcode: error_payload['error_subcode'],
|
141
|
+
fbtrace_id: error_payload['fbtrace_id'],
|
142
|
+
error_data: error_payload['error_data'],
|
143
|
+
retry_hint: build_retry_hint_with_delay(code, http_status, retry_after_ms),
|
144
|
+
raw_response: body
|
145
|
+
)
|
146
|
+
elsif body.is_a?(Hash) && body.key?('error') && body['error'].is_a?(String)
|
147
|
+
# Kapso proxy error format
|
148
|
+
category = http_status >= 500 ? :server : categorize_error_code(nil, http_status)
|
149
|
+
new(
|
150
|
+
message: body['error'],
|
151
|
+
http_status: http_status,
|
152
|
+
code: http_status,
|
153
|
+
category: category,
|
154
|
+
retry_hint: build_retry_hint_with_delay(http_status, http_status, retry_after_ms),
|
155
|
+
raw_response: body
|
156
|
+
)
|
157
|
+
else
|
158
|
+
# Generic HTTP error
|
159
|
+
category = http_status >= 500 ? :server : categorize_error_code(nil, http_status)
|
160
|
+
message = build_default_message(http_status, nil, raw_text)
|
161
|
+
|
162
|
+
new(
|
163
|
+
message: message,
|
164
|
+
http_status: http_status,
|
165
|
+
code: http_status,
|
166
|
+
category: category,
|
167
|
+
retry_hint: build_retry_hint_with_delay(http_status, http_status, retry_after_ms),
|
168
|
+
raw_response: raw_text || body
|
169
|
+
)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
private
|
174
|
+
|
175
|
+
def parse_retry_after(header)
|
176
|
+
return nil unless header
|
177
|
+
|
178
|
+
# Try parsing as number of seconds
|
179
|
+
if header.match?(/^\d+$/)
|
180
|
+
header.to_i * 1000
|
181
|
+
else
|
182
|
+
# Try parsing as HTTP date
|
183
|
+
begin
|
184
|
+
date = Time.parse(header)
|
185
|
+
diff = (date.to_f - Time.now.to_f) * 1000
|
186
|
+
diff > 0 ? diff.to_i : 0
|
187
|
+
rescue ArgumentError
|
188
|
+
nil
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def categorize_error_code(code, http_status)
|
194
|
+
return :authorization if http_status == 401
|
195
|
+
return :permission if http_status == 403
|
196
|
+
return :parameter if http_status == 404
|
197
|
+
return :throttling if http_status == 429
|
198
|
+
return :server if http_status >= 500
|
199
|
+
return :parameter if http_status >= 400 && http_status < 500
|
200
|
+
|
201
|
+
if code
|
202
|
+
ERROR_CODE_CATEGORIES.each do |key, category|
|
203
|
+
if key.is_a?(Range)
|
204
|
+
return category if key.include?(code)
|
205
|
+
elsif key == code
|
206
|
+
return category
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
# Check permission range
|
211
|
+
return :permission if code >= 200 && code <= 299
|
212
|
+
end
|
213
|
+
|
214
|
+
:unknown
|
215
|
+
end
|
216
|
+
|
217
|
+
def build_retry_hint_with_delay(code, http_status, retry_after_ms)
|
218
|
+
if retry_after_ms
|
219
|
+
{ action: :retry_after, retry_after_ms: retry_after_ms }
|
220
|
+
elsif DO_NOT_RETRY_CODES.include?(code)
|
221
|
+
{ action: :do_not_retry }
|
222
|
+
elsif REFRESH_TOKEN_CODES.include?(code)
|
223
|
+
{ action: :refresh_token }
|
224
|
+
elsif http_status >= 500
|
225
|
+
{ action: :retry }
|
226
|
+
else
|
227
|
+
{ action: :fix_and_retry }
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def build_default_message(status, details = nil, raw_text = nil)
|
232
|
+
if details
|
233
|
+
"Meta API request failed with status #{status}: #{details}"
|
234
|
+
elsif raw_text && !raw_text.strip.empty?
|
235
|
+
"Meta API request failed with status #{status}: #{raw_text}"
|
236
|
+
else
|
237
|
+
"Meta API request failed with status #{status}"
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
def auth_error?
|
243
|
+
category == :authorization
|
244
|
+
end
|
245
|
+
|
246
|
+
def rate_limit?
|
247
|
+
category == :throttling
|
248
|
+
end
|
249
|
+
|
250
|
+
def temporary?
|
251
|
+
[:throttling, :server, :synchronization].include?(category) ||
|
252
|
+
http_status >= 500 ||
|
253
|
+
[1, 2, 17, 341].include?(code)
|
254
|
+
end
|
255
|
+
|
256
|
+
def template_error?
|
257
|
+
category == :template
|
258
|
+
end
|
259
|
+
|
260
|
+
def requires_token_refresh?
|
261
|
+
category == :authorization || REFRESH_TOKEN_CODES.include?(code)
|
262
|
+
end
|
263
|
+
|
264
|
+
def retryable?
|
265
|
+
![:do_not_retry].include?(retry_hint[:action])
|
266
|
+
end
|
267
|
+
|
268
|
+
def to_h
|
269
|
+
{
|
270
|
+
name: self.class.name,
|
271
|
+
message: message,
|
272
|
+
http_status: http_status,
|
273
|
+
code: code,
|
274
|
+
type: type,
|
275
|
+
details: details,
|
276
|
+
error_subcode: error_subcode,
|
277
|
+
fbtrace_id: fbtrace_id,
|
278
|
+
category: category,
|
279
|
+
retry_hint: retry_hint,
|
280
|
+
raw_response: raw_response
|
281
|
+
}
|
282
|
+
end
|
283
|
+
|
284
|
+
private
|
285
|
+
|
286
|
+
def categorize_error_code(code, http_status)
|
287
|
+
self.class.send(:categorize_error_code, code, http_status)
|
288
|
+
end
|
289
|
+
|
290
|
+
def derive_retry_hint
|
291
|
+
if DO_NOT_RETRY_CODES.include?(code)
|
292
|
+
{ action: :do_not_retry }
|
293
|
+
elsif REFRESH_TOKEN_CODES.include?(code)
|
294
|
+
{ action: :refresh_token }
|
295
|
+
elsif http_status >= 500
|
296
|
+
{ action: :retry }
|
297
|
+
else
|
298
|
+
{ action: :fix_and_retry }
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def build_error_message
|
303
|
+
if details
|
304
|
+
"Meta API request failed with status #{http_status}: #{details}"
|
305
|
+
elsif raw_response.is_a?(String) && !raw_response.strip.empty?
|
306
|
+
"Meta API request failed with status #{http_status}: #{raw_response}"
|
307
|
+
else
|
308
|
+
"Meta API request failed with status #{http_status}"
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
class KapsoProxyRequiredError < StandardError
|
314
|
+
attr_reader :feature, :help_url
|
315
|
+
|
316
|
+
def initialize(feature)
|
317
|
+
@feature = feature
|
318
|
+
@help_url = 'https://kapso.ai/'
|
319
|
+
|
320
|
+
message = "#{feature} is only available via the Kapso Proxy. " \
|
321
|
+
"Set base_url to https://app.kapso.ai/api/meta and provide kapso_api_key. " \
|
322
|
+
"Create a free account at #{help_url}"
|
323
|
+
super(message)
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
class ConfigurationError < StandardError; end
|
328
|
+
class ValidationError < StandardError; end
|
329
|
+
end
|
330
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Resources
|
5
|
+
class Calls
|
6
|
+
def initialize(client)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
# Initiate a call
|
11
|
+
def connect(phone_number_id:, to:, session: nil, biz_opaque_callback_data: nil)
|
12
|
+
payload = {
|
13
|
+
messaging_product: 'whatsapp',
|
14
|
+
to: to,
|
15
|
+
action: 'connect'
|
16
|
+
}
|
17
|
+
|
18
|
+
payload[:session] = session if session
|
19
|
+
payload[:biz_opaque_callback_data] = biz_opaque_callback_data if biz_opaque_callback_data
|
20
|
+
|
21
|
+
response = @client.request(:post, "#{phone_number_id}/calls",
|
22
|
+
body: payload.to_json, response_type: :json)
|
23
|
+
Types::CallConnectResponse.new(response)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Pre-accept a call
|
27
|
+
def pre_accept(phone_number_id:, call_id:, session:)
|
28
|
+
raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
|
29
|
+
raise ArgumentError, 'session cannot be nil' if session.nil?
|
30
|
+
|
31
|
+
payload = {
|
32
|
+
messaging_product: 'whatsapp',
|
33
|
+
call_id: call_id,
|
34
|
+
action: 'pre_accept',
|
35
|
+
session: session
|
36
|
+
}
|
37
|
+
|
38
|
+
response = @client.request(:post, "#{phone_number_id}/calls",
|
39
|
+
body: payload.to_json, response_type: :json)
|
40
|
+
Types::CallActionResponse.new(response)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Accept a call
|
44
|
+
def accept(phone_number_id:, call_id:, session:, biz_opaque_callback_data: nil)
|
45
|
+
raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
|
46
|
+
raise ArgumentError, 'session cannot be nil' if session.nil?
|
47
|
+
|
48
|
+
payload = {
|
49
|
+
messaging_product: 'whatsapp',
|
50
|
+
call_id: call_id,
|
51
|
+
action: 'accept',
|
52
|
+
session: session
|
53
|
+
}
|
54
|
+
|
55
|
+
payload[:biz_opaque_callback_data] = biz_opaque_callback_data if biz_opaque_callback_data
|
56
|
+
|
57
|
+
response = @client.request(:post, "#{phone_number_id}/calls",
|
58
|
+
body: payload.to_json, response_type: :json)
|
59
|
+
Types::CallActionResponse.new(response)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Reject a call
|
63
|
+
def reject(phone_number_id:, call_id:)
|
64
|
+
raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
|
65
|
+
|
66
|
+
payload = {
|
67
|
+
messaging_product: 'whatsapp',
|
68
|
+
call_id: call_id,
|
69
|
+
action: 'reject'
|
70
|
+
}
|
71
|
+
|
72
|
+
response = @client.request(:post, "#{phone_number_id}/calls",
|
73
|
+
body: payload.to_json, response_type: :json)
|
74
|
+
Types::CallActionResponse.new(response)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Terminate a call
|
78
|
+
def terminate(phone_number_id:, call_id:)
|
79
|
+
raise ArgumentError, 'call_id cannot be empty' if call_id.nil? || call_id.strip.empty?
|
80
|
+
|
81
|
+
payload = {
|
82
|
+
messaging_product: 'whatsapp',
|
83
|
+
call_id: call_id,
|
84
|
+
action: 'terminate'
|
85
|
+
}
|
86
|
+
|
87
|
+
response = @client.request(:post, "#{phone_number_id}/calls",
|
88
|
+
body: payload.to_json, response_type: :json)
|
89
|
+
Types::CallActionResponse.new(response)
|
90
|
+
end
|
91
|
+
|
92
|
+
# List calls (Kapso Proxy only)
|
93
|
+
def list(phone_number_id:, direction: nil, status: nil, since: nil,
|
94
|
+
until_time: nil, call_id: nil, limit: nil, after: nil,
|
95
|
+
before: nil, fields: nil)
|
96
|
+
assert_kapso_proxy('Call history API')
|
97
|
+
|
98
|
+
query_params = {
|
99
|
+
direction: direction,
|
100
|
+
status: status,
|
101
|
+
since: since,
|
102
|
+
until: until_time,
|
103
|
+
call_id: call_id,
|
104
|
+
limit: limit,
|
105
|
+
after: after,
|
106
|
+
before: before,
|
107
|
+
fields: fields
|
108
|
+
}.compact
|
109
|
+
|
110
|
+
response = @client.request(:get, "#{phone_number_id}/calls",
|
111
|
+
query: query_params, response_type: :json)
|
112
|
+
Types::PagedResponse.new(response, Types::CallRecord)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Get call details (Kapso Proxy only)
|
116
|
+
def get(phone_number_id:, call_id:, fields: nil)
|
117
|
+
assert_kapso_proxy('Call details API')
|
118
|
+
|
119
|
+
query_params = {}
|
120
|
+
query_params[:fields] = fields if fields
|
121
|
+
|
122
|
+
response = @client.request(:get, "#{phone_number_id}/calls/#{call_id}",
|
123
|
+
query: query_params, response_type: :json)
|
124
|
+
Types::CallRecord.new(response)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Call permissions management
|
128
|
+
class Permissions
|
129
|
+
def initialize(client)
|
130
|
+
@client = client
|
131
|
+
end
|
132
|
+
|
133
|
+
# Get call permissions
|
134
|
+
def get(phone_number_id:, user_wa_id:)
|
135
|
+
raise ArgumentError, 'user_wa_id cannot be empty' if user_wa_id.nil? || user_wa_id.strip.empty?
|
136
|
+
|
137
|
+
query_params = { user_wa_id: user_wa_id }
|
138
|
+
|
139
|
+
response = @client.request(:get, "#{phone_number_id}/call_permissions",
|
140
|
+
query: query_params, response_type: :json)
|
141
|
+
response
|
142
|
+
end
|
143
|
+
|
144
|
+
# Update call permissions
|
145
|
+
def update(phone_number_id:, user_wa_id:, permission:)
|
146
|
+
raise ArgumentError, 'user_wa_id cannot be empty' if user_wa_id.nil? || user_wa_id.strip.empty?
|
147
|
+
raise ArgumentError, 'permission cannot be empty' if permission.nil?
|
148
|
+
|
149
|
+
payload = {
|
150
|
+
user_wa_id: user_wa_id,
|
151
|
+
permission: permission
|
152
|
+
}
|
153
|
+
|
154
|
+
response = @client.request(:post, "#{phone_number_id}/call_permissions",
|
155
|
+
body: payload.to_json, response_type: :json)
|
156
|
+
Types::GraphSuccessResponse.new(response)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def permissions
|
161
|
+
@permissions ||= Permissions.new(@client)
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
|
166
|
+
def assert_kapso_proxy(feature)
|
167
|
+
unless @client.kapso_proxy?
|
168
|
+
raise Errors::KapsoProxyRequiredError.new(feature)
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WhatsAppCloudApi
|
4
|
+
module Resources
|
5
|
+
class Contacts
|
6
|
+
def initialize(client)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
# List contacts (Kapso Proxy only)
|
11
|
+
def list(phone_number_id:, customer_id: nil, phone_number: nil,
|
12
|
+
profile_name: nil, limit: nil, after: nil, before: nil, fields: nil)
|
13
|
+
assert_kapso_proxy('Contacts API')
|
14
|
+
|
15
|
+
query_params = {
|
16
|
+
customer_id: customer_id,
|
17
|
+
phone_number: phone_number,
|
18
|
+
profile_name: profile_name,
|
19
|
+
limit: limit,
|
20
|
+
after: after,
|
21
|
+
before: before,
|
22
|
+
fields: fields
|
23
|
+
}.compact
|
24
|
+
|
25
|
+
response = @client.request(:get, "#{phone_number_id}/contacts",
|
26
|
+
query: query_params, response_type: :json)
|
27
|
+
Types::PagedResponse.new(response, Types::ContactRecord)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Get contact details (Kapso Proxy only)
|
31
|
+
def get(phone_number_id:, wa_id:, fields: nil)
|
32
|
+
assert_kapso_proxy('Contacts API')
|
33
|
+
|
34
|
+
raise ArgumentError, 'wa_id cannot be empty' if wa_id.nil? || wa_id.strip.empty?
|
35
|
+
|
36
|
+
query_params = {}
|
37
|
+
query_params[:fields] = fields if fields
|
38
|
+
|
39
|
+
response = @client.request(:get, "#{phone_number_id}/contacts/#{wa_id}",
|
40
|
+
query: query_params, response_type: :json)
|
41
|
+
|
42
|
+
# Handle both single object and data envelope responses
|
43
|
+
if response.is_a?(Hash) && response.key?('data')
|
44
|
+
Types::ContactRecord.new(response['data'])
|
45
|
+
else
|
46
|
+
Types::ContactRecord.new(response)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Update contact metadata (Kapso Proxy only)
|
51
|
+
def update(phone_number_id:, wa_id:, metadata: nil, tags: nil,
|
52
|
+
customer_id: nil, notes: nil)
|
53
|
+
assert_kapso_proxy('Contacts API')
|
54
|
+
|
55
|
+
raise ArgumentError, 'wa_id cannot be empty' if wa_id.nil? || wa_id.strip.empty?
|
56
|
+
|
57
|
+
payload = {}
|
58
|
+
payload[:metadata] = metadata if metadata
|
59
|
+
payload[:tags] = tags if tags
|
60
|
+
payload[:customer_id] = customer_id if customer_id
|
61
|
+
payload[:notes] = notes if notes
|
62
|
+
|
63
|
+
return if payload.empty?
|
64
|
+
|
65
|
+
response = @client.request(:patch, "#{phone_number_id}/contacts/#{wa_id}",
|
66
|
+
body: payload.to_json, response_type: :json)
|
67
|
+
Types::GraphSuccessResponse.new(response)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Add tags to contact (Kapso Proxy only)
|
71
|
+
def add_tags(phone_number_id:, wa_id:, tags:)
|
72
|
+
raise ArgumentError, 'tags cannot be empty' if tags.nil? || tags.empty?
|
73
|
+
|
74
|
+
current_contact = get(phone_number_id: phone_number_id, wa_id: wa_id)
|
75
|
+
existing_tags = (current_contact.metadata&.[]('tags') || [])
|
76
|
+
new_tags = (existing_tags + Array(tags)).uniq
|
77
|
+
|
78
|
+
update(phone_number_id: phone_number_id, wa_id: wa_id,
|
79
|
+
metadata: { tags: new_tags })
|
80
|
+
end
|
81
|
+
|
82
|
+
# Remove tags from contact (Kapso Proxy only)
|
83
|
+
def remove_tags(phone_number_id:, wa_id:, tags:)
|
84
|
+
raise ArgumentError, 'tags cannot be empty' if tags.nil? || tags.empty?
|
85
|
+
|
86
|
+
current_contact = get(phone_number_id: phone_number_id, wa_id: wa_id)
|
87
|
+
existing_tags = (current_contact.metadata&.[]('tags') || [])
|
88
|
+
remaining_tags = existing_tags - Array(tags)
|
89
|
+
|
90
|
+
update(phone_number_id: phone_number_id, wa_id: wa_id,
|
91
|
+
metadata: { tags: remaining_tags })
|
92
|
+
end
|
93
|
+
|
94
|
+
# Search contacts by various criteria (Kapso Proxy only)
|
95
|
+
def search(phone_number_id:, query:, search_in: ['profile_name', 'phone_number'],
|
96
|
+
limit: nil, after: nil, before: nil)
|
97
|
+
assert_kapso_proxy('Contacts Search API')
|
98
|
+
|
99
|
+
raise ArgumentError, 'query cannot be empty' if query.nil? || query.strip.empty?
|
100
|
+
|
101
|
+
query_params = {
|
102
|
+
q: query,
|
103
|
+
search_in: Array(search_in).join(','),
|
104
|
+
limit: limit,
|
105
|
+
after: after,
|
106
|
+
before: before
|
107
|
+
}.compact
|
108
|
+
|
109
|
+
response = @client.request(:get, "#{phone_number_id}/contacts/search",
|
110
|
+
query: query_params, response_type: :json)
|
111
|
+
Types::PagedResponse.new(response, Types::ContactRecord)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Get contact analytics (Kapso Proxy only)
|
115
|
+
def analytics(phone_number_id:, wa_id: nil, since: nil, until_time: nil,
|
116
|
+
granularity: 'day', metrics: nil)
|
117
|
+
assert_kapso_proxy('Contact Analytics API')
|
118
|
+
|
119
|
+
query_params = {
|
120
|
+
wa_id: wa_id,
|
121
|
+
since: since,
|
122
|
+
until: until_time,
|
123
|
+
granularity: granularity
|
124
|
+
}
|
125
|
+
query_params[:metrics] = Array(metrics).join(',') if metrics
|
126
|
+
query_params = query_params.compact
|
127
|
+
|
128
|
+
response = @client.request(:get, "#{phone_number_id}/contacts/analytics",
|
129
|
+
query: query_params, response_type: :json)
|
130
|
+
response
|
131
|
+
end
|
132
|
+
|
133
|
+
# Export contacts (Kapso Proxy only)
|
134
|
+
def export(phone_number_id:, format: 'csv', filters: nil)
|
135
|
+
assert_kapso_proxy('Contacts Export API')
|
136
|
+
|
137
|
+
payload = {
|
138
|
+
format: format,
|
139
|
+
filters: filters
|
140
|
+
}.compact
|
141
|
+
|
142
|
+
response = @client.request(:post, "#{phone_number_id}/contacts/export",
|
143
|
+
body: payload.to_json, response_type: :json)
|
144
|
+
response
|
145
|
+
end
|
146
|
+
|
147
|
+
# Import contacts (Kapso Proxy only)
|
148
|
+
def import(phone_number_id:, file:, format: 'csv', mapping: nil,
|
149
|
+
duplicate_handling: 'skip')
|
150
|
+
assert_kapso_proxy('Contacts Import API')
|
151
|
+
|
152
|
+
# Build multipart form data
|
153
|
+
form_data = {
|
154
|
+
'format' => format,
|
155
|
+
'duplicate_handling' => duplicate_handling
|
156
|
+
}
|
157
|
+
|
158
|
+
# Handle file parameter
|
159
|
+
file_obj = case file
|
160
|
+
when String
|
161
|
+
File.open(file, 'rb')
|
162
|
+
when File, IO, StringIO
|
163
|
+
file
|
164
|
+
else
|
165
|
+
raise ArgumentError, 'file must be a File, IO object, or file path string'
|
166
|
+
end
|
167
|
+
|
168
|
+
form_data['file'] = Faraday::UploadIO.new(file_obj, 'text/csv', 'contacts.csv')
|
169
|
+
form_data['mapping'] = mapping.to_json if mapping
|
170
|
+
|
171
|
+
headers = { 'Content-Type' => 'multipart/form-data' }
|
172
|
+
|
173
|
+
response = @client.request(:post, "#{phone_number_id}/contacts/import",
|
174
|
+
body: form_data, headers: headers, response_type: :json)
|
175
|
+
|
176
|
+
# Close file if we opened it
|
177
|
+
file_obj.close if file.is_a?(String) && file_obj.respond_to?(:close)
|
178
|
+
|
179
|
+
response
|
180
|
+
end
|
181
|
+
|
182
|
+
private
|
183
|
+
|
184
|
+
def assert_kapso_proxy(feature)
|
185
|
+
unless @client.kapso_proxy?
|
186
|
+
raise Errors::KapsoProxyRequiredError.new(feature)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|