postproxy-sdk 1.12.0 → 1.14.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,35 @@
1
+ module PostProxy
2
+ module Resources
3
+ # Account-wide API keys only — placements span profile groups.
4
+ class Placements
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def list(page: nil, per_page: nil)
10
+ params = {}
11
+ params[:page] = page if page
12
+ params[:per_page] = per_page if per_page
13
+
14
+ result = @client.request(:get, "/placements", params: params.empty? ? nil : params)
15
+ placements = (result[:data] || []).map { |p| AccountPlacement.new(**p) }
16
+ PaginatedResponse.new(
17
+ data: placements,
18
+ total: result[:total],
19
+ page: result[:page],
20
+ per_page: result[:per_page]
21
+ )
22
+ end
23
+
24
+ # When the same placement is reachable through several connected
25
+ # profiles, choose which one PostProxy posts and reads through.
26
+ def set_active_profile(id, profile_id, idempotency_key: nil)
27
+ result = @client.request(:patch, "/placements/#{id}/set_active_profile",
28
+ json: { profile_id: profile_id },
29
+ idempotency_key: idempotency_key
30
+ )
31
+ AccountPlacement.new(**result)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -28,13 +28,20 @@ module PostProxy
28
28
  Post.new(**result)
29
29
  end
30
30
 
31
+ # `media` items are URL strings or `{ url: }` hashes. A local
32
+ # `cover_file` in `platforms` (Instagram, TikTok, YouTube, Pinterest) is
33
+ # uploaded with the post, switching the request to multipart; a base64
34
+ # data URL is sent as-is. `skip_duplicate_check: true` publishes even if
35
+ # an identical post was just submitted (no ConflictError).
31
36
  def create(body, profiles:, media: nil, media_files: nil, platforms: nil,
32
37
  thread: nil, scheduled_at: nil, draft: nil, queue_id: nil,
33
- queue_priority: nil, profile_group_id: nil, idempotency_key: nil)
38
+ queue_priority: nil, profile_group_id: nil, idempotency_key: nil,
39
+ skip_duplicate_check: nil)
34
40
  has_files = media_files && !media_files.empty?
35
41
  has_thread_files = thread&.any? { |t| t[:media_files]&.any? }
42
+ headers = skip_duplicate_check ? { "X-Skip-Duplicate-Check" => "true" } : nil
36
43
 
37
- if has_files || has_thread_files
44
+ if has_files || has_thread_files || local_cover_file?(platforms)
38
45
  form_data = { "post[body]" => body }
39
46
  form_data["post[scheduled_at]"] = format_time(scheduled_at) if scheduled_at
40
47
  form_data["post[draft]"] = draft.to_s if !draft.nil?
@@ -48,17 +55,10 @@ module PostProxy
48
55
  end
49
56
 
50
57
  media&.each do |m|
51
- files << ["media[]", nil, m, "text/plain"]
58
+ files << ["media[]", nil, media_url(m), "text/plain"]
52
59
  end
53
60
 
54
- if platforms
55
- params_hash = platforms.is_a?(PlatformParams) ? platforms.to_h : platforms
56
- params_hash.each do |platform, platform_params|
57
- platform_params.each do |key, value|
58
- files << ["platforms[#{platform}][#{key}]", nil, value.to_s, "text/plain"]
59
- end
60
- end
61
- end
61
+ append_platform_params(files, platforms) if platforms
62
62
 
63
63
  media_files&.each do |path|
64
64
  path = path.to_s
@@ -72,7 +72,7 @@ module PostProxy
72
72
  form_data["thread[#{i}][body]"] = t[:body] if t[:body]
73
73
 
74
74
  t[:media]&.each do |m|
75
- files << ["thread[#{i}][media][]", nil, m, "text/plain"]
75
+ files << ["thread[#{i}][media][]", nil, media_url(m), "text/plain"]
76
76
  end
77
77
 
78
78
  t[:media_files]&.each do |path|
@@ -88,7 +88,8 @@ module PostProxy
88
88
  data: form_data,
89
89
  files: files,
90
90
  profile_group_id: profile_group_id,
91
- idempotency_key: idempotency_key
91
+ idempotency_key: idempotency_key,
92
+ headers: headers
92
93
  )
93
94
  else
94
95
  post_payload = { body: body }
@@ -105,20 +106,25 @@ module PostProxy
105
106
  result = @client.request(:post, "/posts",
106
107
  json: json_body,
107
108
  profile_group_id: profile_group_id,
108
- idempotency_key: idempotency_key
109
+ idempotency_key: idempotency_key,
110
+ headers: headers
109
111
  )
110
112
  end
111
113
 
112
114
  Post.new(**result)
113
115
  end
114
116
 
117
+ # On a published post only `body`, `platforms.youtube.title`,
118
+ # `platforms.pinterest.title` and `platforms.pinterest.destination_link`
119
+ # can change; the edit is pushed to the platforms in the background
120
+ # (`platform_post.edited` / `platform_post.edit_failed` webhooks).
115
121
  def update(id, body: nil, profiles: nil, media: nil, media_files: nil, platforms: nil,
116
122
  thread: nil, scheduled_at: nil, draft: nil, queue_id: nil,
117
123
  queue_priority: nil, profile_group_id: nil, idempotency_key: nil)
118
124
  has_files = media_files && !media_files.empty?
119
125
  has_thread_files = thread&.any? { |t| t[:media_files]&.any? }
120
126
 
121
- if has_files || has_thread_files
127
+ if has_files || has_thread_files || local_cover_file?(platforms)
122
128
  form_data = {}
123
129
  form_data["post[body]"] = body if body
124
130
  form_data["post[scheduled_at]"] = format_time(scheduled_at) if scheduled_at
@@ -133,17 +139,10 @@ module PostProxy
133
139
  end
134
140
 
135
141
  media&.each do |m|
136
- files << ["media[]", nil, m, "text/plain"]
142
+ files << ["media[]", nil, media_url(m), "text/plain"]
137
143
  end
138
144
 
139
- if platforms
140
- params_hash = platforms.is_a?(PlatformParams) ? platforms.to_h : platforms
141
- params_hash.each do |platform, platform_params|
142
- platform_params.each do |key, value|
143
- files << ["platforms[#{platform}][#{key}]", nil, value.to_s, "text/plain"]
144
- end
145
- end
146
- end
145
+ append_platform_params(files, platforms) if platforms
147
146
 
148
147
  media_files&.each do |path|
149
148
  path = path.to_s
@@ -157,7 +156,7 @@ module PostProxy
157
156
  form_data["thread[#{i}][body]"] = t[:body] if t[:body]
158
157
 
159
158
  t[:media]&.each do |m|
160
- files << ["thread[#{i}][media][]", nil, m, "text/plain"]
159
+ files << ["thread[#{i}][media][]", nil, media_url(m), "text/plain"]
161
160
  end
162
161
 
163
162
  t[:media_files]&.each do |path|
@@ -249,6 +248,68 @@ module PostProxy
249
248
 
250
249
  private
251
250
 
251
+ def media_url(item)
252
+ return item unless item.is_a?(Hash)
253
+
254
+ item[:url] || item["url"]
255
+ end
256
+
257
+ # `cover_file` is a local path unless it's a base64 data URL, which the
258
+ # API takes as a plain string.
259
+ def local_cover_file_value?(key, value)
260
+ key.to_s == "cover_file" && value.is_a?(String) && !value.start_with?("data:")
261
+ end
262
+
263
+ def local_cover_file?(platforms)
264
+ return false unless platforms
265
+
266
+ params_hash = platforms.is_a?(PlatformParams) ? platforms.to_h : platforms
267
+ params_hash.any? do |_, platform_params|
268
+ platform_params = platform_params.to_h if platform_params.is_a?(Model)
269
+ platform_params.is_a?(Hash) && platform_params.any? { |k, v| local_cover_file_value?(k, v) }
270
+ end
271
+ end
272
+
273
+ # Platform params as flat form fields; a local `cover_file` is attached
274
+ # as a file.
275
+ def append_platform_params(files, platforms)
276
+ params_hash = platforms.is_a?(PlatformParams) ? platforms.to_h : platforms
277
+ params_hash.each do |platform, platform_params|
278
+ next if platform_params.nil?
279
+
280
+ platform_params = platform_params.to_h if platform_params.is_a?(Model)
281
+ platform_params.each do |key, value|
282
+ next if value.nil?
283
+
284
+ field = "platforms[#{platform}][#{key}]"
285
+ if local_cover_file_value?(key, value)
286
+ filename = File.basename(value)
287
+ files << [field, filename, File.open(value, "rb"), mime_type_for(filename)]
288
+ else
289
+ append_form_value(files, field, value)
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ def append_form_value(files, field, value)
296
+ case value
297
+ when Array
298
+ # Hashes (e.g. Instagram user tags) get an index so their fields stay
299
+ # grouped; Rails reads numeric keys back as an array.
300
+ value.each_with_index do |item, i|
301
+ nested = item.is_a?(Hash) || item.is_a?(Model)
302
+ append_form_value(files, nested ? "#{field}[#{i}]" : "#{field}[]", item)
303
+ end
304
+ when Hash
305
+ value.each { |k, v| append_form_value(files, "#{field}[#{k}]", v) unless v.nil? }
306
+ when Model
307
+ append_form_value(files, field, value.to_h)
308
+ else
309
+ files << [field, nil, value.to_s, "text/plain"]
310
+ end
311
+ end
312
+
252
313
  def format_time(value)
253
314
  return value if value.is_a?(String)
254
315
  value.iso8601
@@ -30,9 +30,16 @@ module PostProxy
30
30
  end
31
31
 
32
32
  # OAuth flow. BlueSky and Telegram use their dedicated helpers below.
33
- def initialize_connection(id, platform:, redirect_url: nil)
33
+ #
34
+ # `onboarding` picks the flow: Instagram "instagram" (default) or
35
+ # "facebook" (Facebook login — needed for reel music); WhatsApp "api" or
36
+ # "business_app". `refresh_connection: true` re-authorizes an existing
37
+ # profile.
38
+ def initialize_connection(id, platform:, redirect_url: nil, onboarding: nil, refresh_connection: nil)
34
39
  body = { platform: platform }
35
40
  body[:redirect_url] = redirect_url if redirect_url
41
+ body[:onboarding] = onboarding if onboarding
42
+ body[:refresh_connection] = refresh_connection unless refresh_connection.nil?
36
43
  result = @client.request(:post, "/profile_groups/#{id}/initialize_connection", json: body)
37
44
  ConnectionResponse.new(**result)
38
45
  end
@@ -1,3 +1,5 @@
1
+ require "erb"
2
+
1
3
  module PostProxy
2
4
  module Resources
3
5
  class Profiles
@@ -36,6 +38,97 @@ module PostProxy
36
38
  ProfileStatsResponse.new(data: result[:data])
37
39
  end
38
40
 
41
+ # Per-day series. `attribution: "publish"` attributes post metrics to the
42
+ # day the post was published instead of the day they were received.
43
+ def daily_stats(id, placement_id: nil, from: nil, to: nil, attribution: nil, profile_group_id: nil)
44
+ params = {}
45
+ params[:placement_id] = placement_id if placement_id
46
+ params[:from] = from if from
47
+ params[:to] = to if to
48
+ params[:attribution] = attribution if attribution
49
+
50
+ result = @client.request(:get, "/profiles/#{id}/daily_stats",
51
+ params: params.empty? ? nil : params,
52
+ profile_group_id: profile_group_id
53
+ )
54
+ DailyStatsResponse.new(data: result[:data])
55
+ end
56
+
57
+ def follower_history(id, placement_id: nil, from: nil, to: nil, profile_group_id: nil)
58
+ params = {}
59
+ params[:placement_id] = placement_id if placement_id
60
+ params[:from] = from if from
61
+ params[:to] = to if to
62
+
63
+ result = @client.request(:get, "/profiles/#{id}/follower_history",
64
+ params: params.empty? ? nil : params,
65
+ profile_group_id: profile_group_id
66
+ )
67
+ FollowerHistoryResponse.new(data: result[:data])
68
+ end
69
+
70
+ # Instagram only: whether a DM participant is verified and follows you.
71
+ def participant_status(id, user_id, profile_group_id: nil)
72
+ result = @client.request(:get, "/profiles/#{id}/participants/#{ERB::Util.url_encode(user_id.to_s)}",
73
+ profile_group_id: profile_group_id
74
+ )
75
+ ParticipantStatus.new(**result)
76
+ end
77
+
78
+ # LinkedIn only: resolve a company page URL (or vanity name) to the URN
79
+ # used to @-mention it in a post.
80
+ def resolve_mention(id, url, profile_group_id: nil)
81
+ result = @client.request(:get, "/profiles/#{id}/resolve_mention",
82
+ params: { url: url },
83
+ profile_group_id: profile_group_id
84
+ )
85
+ ResolvedMention.new(**result)
86
+ end
87
+
88
+ # Music and original sounds an Instagram reel can attach via
89
+ # `platforms.instagram.audio_id`. Profiles connected via Facebook login
90
+ # only. `audio_type` is "music" or "original_sound". Returns the
91
+ # response hash (`data` plus paging).
92
+ def instagram_available_audio(id, audio_type: nil, search_query: nil, profile_group_id: nil)
93
+ params = {}
94
+ params[:audio_type] = audio_type if audio_type
95
+ params[:search_query] = search_query if search_query
96
+
97
+ @client.request(:get, "/profiles/#{id}/instagram/available_audio",
98
+ params: params.empty? ? nil : params,
99
+ profile_group_id: profile_group_id
100
+ )
101
+ end
102
+
103
+ # Facebook only. `page_id` is the placement id from #placements. Returns
104
+ # the Page fields as a hash.
105
+ def facebook_page(id, page_id, fields: nil, profile_group_id: nil)
106
+ params = { page_id: page_id }
107
+ params[:fields] = fields if fields
108
+
109
+ @client.request(:get, "/profiles/#{id}/facebook/page", params: params, profile_group_id: profile_group_id)
110
+ end
111
+
112
+ # Update the Page's about / contact fields, e.g. `about` (255 characters
113
+ # or fewer), `description`, `website`, `phone`, `emails`.
114
+ def update_facebook_page(id, page_id, fields, profile_group_id: nil, idempotency_key: nil)
115
+ @client.request(:patch, "/profiles/#{id}/facebook/update_page",
116
+ json: fields.merge(page_id: page_id),
117
+ profile_group_id: profile_group_id,
118
+ idempotency_key: idempotency_key
119
+ )
120
+ end
121
+
122
+ # Moves the profile (with its placements) to another profile group.
123
+ def assign_to_group(id, target_profile_group_id, profile_group_id: nil, idempotency_key: nil)
124
+ result = @client.request(:patch, "/profiles/#{id}/assign_to_group",
125
+ json: { target_profile_group_id: target_profile_group_id },
126
+ profile_group_id: profile_group_id,
127
+ idempotency_key: idempotency_key
128
+ )
129
+ Profile.new(**result)
130
+ end
131
+
39
132
  # Moves a placement (e.g. a Facebook Page or Telegram channel) to another
40
133
  # profile group. `placement_id` is the placement's external ID as
41
134
  # returned by #placements.
@@ -97,6 +190,44 @@ module PostProxy
97
190
  PostSync.new(**result)
98
191
  end
99
192
 
193
+ # Imports DM history back to `from`. Like #backfill_posts, runs in the
194
+ # background and only one runs per profile.
195
+ def backfill_chats(id, from:, profile_group_id: nil, idempotency_key: nil)
196
+ result = @client.request(:post, "/profiles/#{id}/backfill_chats",
197
+ json: { from: from },
198
+ profile_group_id: profile_group_id,
199
+ idempotency_key: idempotency_key
200
+ )
201
+ ChatSync.new(**result)
202
+ end
203
+
204
+ def chat_syncs(id, trigger: nil, status: nil, page: nil, per_page: nil, profile_group_id: nil)
205
+ params = {}
206
+ params[:trigger] = trigger if trigger
207
+ params[:status] = status if status
208
+ params[:page] = page if page
209
+ params[:per_page] = per_page if per_page
210
+
211
+ result = @client.request(:get, "/profiles/#{id}/chat_syncs",
212
+ params: params.empty? ? nil : params,
213
+ profile_group_id: profile_group_id
214
+ )
215
+ syncs = (result[:data] || []).map { |s| ChatSync.new(**s) }
216
+ PaginatedResponse.new(
217
+ data: syncs,
218
+ total: result[:total],
219
+ page: result[:page],
220
+ per_page: result[:per_page]
221
+ )
222
+ end
223
+
224
+ def chat_sync(id, chat_sync_id, profile_group_id: nil)
225
+ result = @client.request(:get, "/profiles/#{id}/chat_syncs/#{chat_sync_id}",
226
+ profile_group_id: profile_group_id
227
+ )
228
+ ChatSync.new(**result)
229
+ end
230
+
100
231
  # Lists DM ice breakers. Supported for Instagram profiles only.
101
232
  def ice_breakers(id, profile_group_id: nil)
102
233
  result = @client.request(:get, "/profiles/#{id}/ice_breakers", profile_group_id: profile_group_id)
@@ -0,0 +1,278 @@
1
+ module PostProxy
2
+ module Resources
3
+ # WhatsApp number administration: registration, business profile, display
4
+ # name, username, blocked users, groups and conversion events. Responses
5
+ # are Meta's payloads, returned as plain hashes.
6
+ #
7
+ # `phone_number_id` is the placement id from
8
+ # `profiles.placements(profile_id)`. Dataset and conversion calls are
9
+ # account-level and don't take one.
10
+ class WhatsApp
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ # --- Phone number ---
16
+
17
+ def number_info(profile_id, phone_number_id, profile_group_id: nil)
18
+ call(:get, profile_id, "number_info", { phone_number_id: phone_number_id }, profile_group_id)
19
+ end
20
+
21
+ # `pin` is the 6-digit two-step verification PIN.
22
+ def register_phone_number(profile_id, phone_number_id, pin, profile_group_id: nil, idempotency_key: nil)
23
+ call(:post, profile_id, "register_phone_number",
24
+ { phone_number_id: phone_number_id, pin: pin }, profile_group_id, idempotency_key)
25
+ end
26
+
27
+ # `method` is "SMS" or "VOICE".
28
+ def request_verification_code(profile_id, phone_number_id, method: nil, language: nil, profile_group_id: nil,
29
+ idempotency_key: nil)
30
+ call(:post, profile_id, "request_verification_code",
31
+ { phone_number_id: phone_number_id, method: method, language: language }, profile_group_id, idempotency_key)
32
+ end
33
+
34
+ def verify_phone_number(profile_id, phone_number_id, code, profile_group_id: nil, idempotency_key: nil)
35
+ call(:post, profile_id, "verify_phone_number",
36
+ { phone_number_id: phone_number_id, code: code }, profile_group_id, idempotency_key)
37
+ end
38
+
39
+ # --- Business profile ---
40
+
41
+ def business_profile(profile_id, phone_number_id, profile_group_id: nil)
42
+ call(:get, profile_id, "business_profile", { phone_number_id: phone_number_id }, profile_group_id)
43
+ end
44
+
45
+ def update_business_profile(profile_id, phone_number_id, about: nil, address: nil, description: nil,
46
+ email: nil, websites: nil, vertical: nil, profile_group_id: nil, idempotency_key: nil)
47
+ call(:patch, profile_id, "update_business_profile", {
48
+ phone_number_id: phone_number_id,
49
+ about: about,
50
+ address: address,
51
+ description: description,
52
+ email: email,
53
+ websites: websites,
54
+ vertical: vertical
55
+ }, profile_group_id, idempotency_key)
56
+ end
57
+
58
+ # Pass a public `url`, or base64 `data` with its `content_type`
59
+ # (image/jpeg or image/png).
60
+ def update_business_profile_photo(profile_id, phone_number_id, url: nil, data: nil, content_type: nil,
61
+ profile_group_id: nil, idempotency_key: nil)
62
+ call(:post, profile_id, "update_business_profile_photo", {
63
+ phone_number_id: phone_number_id,
64
+ url: url,
65
+ data: data,
66
+ content_type: content_type
67
+ }, profile_group_id, idempotency_key)
68
+ end
69
+
70
+ # --- Display name ---
71
+
72
+ def display_name(profile_id, phone_number_id, profile_group_id: nil)
73
+ call(:get, profile_id, "display_name", { phone_number_id: phone_number_id }, profile_group_id)
74
+ end
75
+
76
+ # Meta reviews the new name; the outcome arrives as the
77
+ # `placement.name_status_updated` webhook.
78
+ def request_display_name_change(profile_id, phone_number_id, display_name, profile_group_id: nil,
79
+ idempotency_key: nil)
80
+ call(:post, profile_id, "request_display_name_change",
81
+ { phone_number_id: phone_number_id, display_name: display_name }, profile_group_id, idempotency_key)
82
+ end
83
+
84
+ # --- Username ---
85
+
86
+ def username(profile_id, phone_number_id, profile_group_id: nil)
87
+ call(:get, profile_id, "username", { phone_number_id: phone_number_id }, profile_group_id)
88
+ end
89
+
90
+ # `transfer_action` is "none" or "force_transfer".
91
+ def set_username(profile_id, phone_number_id, username, transfer_action: nil, profile_group_id: nil,
92
+ idempotency_key: nil)
93
+ call(:put, profile_id, "set_username",
94
+ { phone_number_id: phone_number_id, username: username, transfer_action: transfer_action },
95
+ profile_group_id, idempotency_key)
96
+ end
97
+
98
+ def delete_username(profile_id, phone_number_id, profile_group_id: nil, idempotency_key: nil)
99
+ call(:delete, profile_id, "delete_username", { phone_number_id: phone_number_id }, profile_group_id,
100
+ idempotency_key)
101
+ end
102
+
103
+ def username_suggestions(profile_id, phone_number_id, profile_group_id: nil)
104
+ call(:get, profile_id, "username_suggestions", { phone_number_id: phone_number_id }, profile_group_id)
105
+ end
106
+
107
+ # --- Blocked users ---
108
+
109
+ def blocked_users(profile_id, phone_number_id, limit: nil, after: nil, profile_group_id: nil)
110
+ call(:get, profile_id, "blocked_users",
111
+ { phone_number_id: phone_number_id, limit: limit, after: after }, profile_group_id)
112
+ end
113
+
114
+ def blocked_user_status(profile_id, phone_number_id, user, profile_group_id: nil)
115
+ call(:get, profile_id, "blocked_user_status", { phone_number_id: phone_number_id, user: user },
116
+ profile_group_id)
117
+ end
118
+
119
+ # `users` are phone numbers or WhatsApp ids.
120
+ def block_users(profile_id, phone_number_id, users, profile_group_id: nil, idempotency_key: nil)
121
+ call(:post, profile_id, "block_users", { phone_number_id: phone_number_id, users: users },
122
+ profile_group_id, idempotency_key)
123
+ end
124
+
125
+ def unblock_users(profile_id, phone_number_id, users, profile_group_id: nil, idempotency_key: nil)
126
+ call(:post, profile_id, "unblock_users", { phone_number_id: phone_number_id, users: users },
127
+ profile_group_id, idempotency_key)
128
+ end
129
+
130
+ # --- Groups ---
131
+
132
+ def groups(profile_id, phone_number_id, limit: nil, after: nil, profile_group_id: nil)
133
+ call(:get, profile_id, "groups", { phone_number_id: phone_number_id, limit: limit, after: after },
134
+ profile_group_id)
135
+ end
136
+
137
+ # `join_approval_mode` is "approval_required" or "auto_approve".
138
+ def create_group(profile_id, phone_number_id, subject, description: nil, join_approval_mode: nil,
139
+ profile_group_id: nil, idempotency_key: nil)
140
+ call(:post, profile_id, "create_group", {
141
+ phone_number_id: phone_number_id,
142
+ subject: subject,
143
+ description: description,
144
+ join_approval_mode: join_approval_mode
145
+ }, profile_group_id, idempotency_key)
146
+ end
147
+
148
+ def group(profile_id, phone_number_id, group_id, profile_group_id: nil)
149
+ call(:get, profile_id, "group", { phone_number_id: phone_number_id, group_id: group_id }, profile_group_id)
150
+ end
151
+
152
+ def update_group(profile_id, phone_number_id, group_id, subject: nil, description: nil,
153
+ join_approval_mode: nil, profile_group_id: nil, idempotency_key: nil)
154
+ call(:patch, profile_id, "update_group", {
155
+ phone_number_id: phone_number_id,
156
+ group_id: group_id,
157
+ subject: subject,
158
+ description: description,
159
+ join_approval_mode: join_approval_mode
160
+ }, profile_group_id, idempotency_key)
161
+ end
162
+
163
+ def delete_group(profile_id, phone_number_id, group_id, profile_group_id: nil, idempotency_key: nil)
164
+ call(:delete, profile_id, "delete_group", { phone_number_id: phone_number_id, group_id: group_id },
165
+ profile_group_id, idempotency_key)
166
+ end
167
+
168
+ def add_group_participants(profile_id, phone_number_id, group_id, phone_numbers, profile_group_id: nil,
169
+ idempotency_key: nil)
170
+ group_members(:post, profile_id, "add_group_participants", phone_number_id, group_id, phone_numbers,
171
+ profile_group_id, idempotency_key)
172
+ end
173
+
174
+ def remove_group_participants(profile_id, phone_number_id, group_id, phone_numbers, profile_group_id: nil,
175
+ idempotency_key: nil)
176
+ group_members(:post, profile_id, "remove_group_participants", phone_number_id, group_id, phone_numbers,
177
+ profile_group_id, idempotency_key)
178
+ end
179
+
180
+ def create_group_invite_link(profile_id, phone_number_id, group_id, profile_group_id: nil,
181
+ idempotency_key: nil)
182
+ call(:post, profile_id, "create_group_invite_link",
183
+ { phone_number_id: phone_number_id, group_id: group_id }, profile_group_id, idempotency_key)
184
+ end
185
+
186
+ def group_join_requests(profile_id, phone_number_id, group_id, profile_group_id: nil)
187
+ call(:get, profile_id, "group_join_requests", { phone_number_id: phone_number_id, group_id: group_id },
188
+ profile_group_id)
189
+ end
190
+
191
+ def approve_group_join_requests(profile_id, phone_number_id, group_id, phone_numbers, profile_group_id: nil,
192
+ idempotency_key: nil)
193
+ group_members(:post, profile_id, "approve_group_join_requests", phone_number_id, group_id, phone_numbers,
194
+ profile_group_id, idempotency_key)
195
+ end
196
+
197
+ def reject_group_join_requests(profile_id, phone_number_id, group_id, phone_numbers, profile_group_id: nil,
198
+ idempotency_key: nil)
199
+ group_members(:post, profile_id, "reject_group_join_requests", phone_number_id, group_id, phone_numbers,
200
+ profile_group_id, idempotency_key)
201
+ end
202
+
203
+ # --- Conversions (account-level) ---
204
+
205
+ def dataset(profile_id, profile_group_id: nil)
206
+ call(:get, profile_id, "dataset", {}, profile_group_id)
207
+ end
208
+
209
+ def create_dataset(profile_id, profile_group_id: nil, idempotency_key: nil)
210
+ call(:post, profile_id, "create_dataset", {}, profile_group_id, idempotency_key)
211
+ end
212
+
213
+ # Report a conversion (e.g. Purchase, LeadSubmitted) back to Meta for a
214
+ # click-to-WhatsApp ad. Identify the person by `chat_id` or `phone`.
215
+ def send_conversion_event(profile_id, event_name:, event_id:, chat_id: nil, phone: nil, dataset_id: nil,
216
+ event_time: nil, email: nil, external_id: nil, currency: nil, value: nil, content_ids: nil,
217
+ test_code: nil, profile_group_id: nil, idempotency_key: nil)
218
+ call(:post, profile_id, "send_conversion_event", {
219
+ event_name: event_name,
220
+ event_id: event_id,
221
+ chat_id: chat_id,
222
+ phone: phone,
223
+ dataset_id: dataset_id,
224
+ event_time: event_time,
225
+ email: email,
226
+ external_id: external_id,
227
+ currency: currency,
228
+ value: value,
229
+ content_ids: content_ids,
230
+ test_code: test_code
231
+ }, profile_group_id, idempotency_key)
232
+ end
233
+
234
+ # --- Media ---
235
+
236
+ # Download an inbound media file (e.g. from a message attachment's
237
+ # `external_id`). Returns a RawResponse: `data` holds the raw bytes,
238
+ # `content_type` the MIME type.
239
+ def media(profile_id, phone_number_id, media_id, profile_group_id: nil)
240
+ @client.request(:get, "/profiles/#{profile_id}/whatsapp/media",
241
+ params: { phone_number_id: phone_number_id, media_id: media_id },
242
+ profile_group_id: profile_group_id,
243
+ raw: true
244
+ )
245
+ end
246
+
247
+ private
248
+
249
+ # Reads and deletes send their arguments as query params; everything
250
+ # else as a JSON body. Nil values are dropped.
251
+ def call(http_method, profile_id, action, data, profile_group_id, idempotency_key = nil)
252
+ path = "/profiles/#{profile_id}/whatsapp/#{action}"
253
+ compact = data.compact
254
+
255
+ if %i[get delete].include?(http_method)
256
+ @client.request(http_method, path,
257
+ params: compact.empty? ? nil : compact,
258
+ profile_group_id: profile_group_id,
259
+ idempotency_key: idempotency_key
260
+ )
261
+ else
262
+ @client.request(http_method, path,
263
+ json: compact,
264
+ profile_group_id: profile_group_id,
265
+ idempotency_key: idempotency_key
266
+ )
267
+ end
268
+ end
269
+
270
+ def group_members(http_method, profile_id, action, phone_number_id, group_id, phone_numbers,
271
+ profile_group_id, idempotency_key)
272
+ call(http_method, profile_id, action,
273
+ { phone_number_id: phone_number_id, group_id: group_id, phone_numbers: phone_numbers },
274
+ profile_group_id, idempotency_key)
275
+ end
276
+ end
277
+ end
278
+ end