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.
@@ -10,6 +10,11 @@ require_relative "resources/comments"
10
10
  require_relative "resources/profile_comments"
11
11
  require_relative "resources/chats"
12
12
  require_relative "resources/messages"
13
+ require_relative "resources/placements"
14
+ require_relative "resources/api_keys"
15
+ require_relative "resources/google_business"
16
+ require_relative "resources/whatsapp_templates"
17
+ require_relative "resources/whatsapp"
13
18
 
14
19
  module PostProxy
15
20
  class Client
@@ -29,6 +34,11 @@ module PostProxy
29
34
  @profile_comments = nil
30
35
  @chats = nil
31
36
  @messages = nil
37
+ @placements = nil
38
+ @api_keys = nil
39
+ @google_business = nil
40
+ @whatsapp_templates = nil
41
+ @whatsapp = nil
32
42
  end
33
43
 
34
44
  def posts
@@ -67,8 +77,30 @@ module PostProxy
67
77
  @messages ||= Resources::Messages.new(self)
68
78
  end
69
79
 
80
+ def placements
81
+ @placements ||= Resources::Placements.new(self)
82
+ end
83
+
84
+ def api_keys
85
+ @api_keys ||= Resources::ApiKeys.new(self)
86
+ end
87
+
88
+ def google_business
89
+ @google_business ||= Resources::GoogleBusiness.new(self)
90
+ end
91
+
92
+ def whatsapp_templates
93
+ @whatsapp_templates ||= Resources::WhatsAppTemplates.new(self)
94
+ end
95
+
96
+ def whatsapp
97
+ @whatsapp ||= Resources::WhatsApp.new(self)
98
+ end
99
+
100
+ # `raw: true` returns the body bytes and content type instead of parsing
101
+ # JSON (used for binary downloads).
70
102
  def request(method, path, params: nil, json: nil, data: nil, files: nil, profile_group_id: nil,
71
- idempotency_key: nil)
103
+ idempotency_key: nil, headers: nil, raw: false)
72
104
  url = "/api#{path}"
73
105
 
74
106
  query = {}
@@ -92,18 +124,24 @@ module PostProxy
92
124
  parts << [field, Faraday::Multipart::FilePart.new(io, content_type, filename)]
93
125
  end
94
126
  end
95
- # Build payload preserving duplicate keys
96
- payload = parts.each_with_object({}) do |(key, val), h|
97
- if h.key?(key)
98
- h[key] = [h[key]] unless h[key].is_a?(Array)
99
- h[key] << val
127
+ # Build payload preserving duplicate keys. Repeated "foo[]"
128
+ # parts are grouped under "foo" — Faraday appends "[]" to
129
+ # each array item itself.
130
+ grouped = parts.group_by(&:first)
131
+ payload = grouped.each_with_object({}) do |(key, pairs), h|
132
+ values = pairs.map(&:last)
133
+ if values.size == 1
134
+ h[key] = values.first
135
+ elsif key.end_with?("[]")
136
+ h[key.delete_suffix("[]")] = values
100
137
  else
101
- h[key] = val
138
+ h[key] = values
102
139
  end
103
140
  end
104
141
  conn.send(method, url) do |req|
105
142
  req.params = query unless query.empty?
106
143
  req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
144
+ headers&.each { |k, v| req.headers[k] = v }
107
145
  req.body = payload
108
146
  end
109
147
  else
@@ -111,10 +149,15 @@ module PostProxy
111
149
  conn.send(method, url) do |req|
112
150
  req.params = query unless query.empty?
113
151
  req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
152
+ headers&.each { |k, v| req.headers[k] = v }
114
153
  req.body = json.to_json if json
115
154
  end
116
155
  end
117
156
 
157
+ if raw && (200..299).cover?(response.status)
158
+ return RawResponse.new(data: response.body.to_s.b, content_type: response.headers["content-type"])
159
+ end
160
+
118
161
  handle_response(response)
119
162
  end
120
163
 
@@ -156,6 +199,21 @@ module PostProxy
156
199
  status_code: response.status,
157
200
  response: body
158
201
  )
202
+ when 403
203
+ body = parse_error_body(response)
204
+ raise ForbiddenError.new(
205
+ error_message(body),
206
+ status_code: response.status,
207
+ response: body
208
+ )
209
+ when 429
210
+ body = parse_error_body(response)
211
+ raise RateLimitError.new(
212
+ error_message(body),
213
+ status_code: response.status,
214
+ response: body,
215
+ retry_after: parse_retry_after(response.headers["retry-after"])
216
+ )
159
217
  when 404
160
218
  body = parse_error_body(response)
161
219
  raise NotFoundError.new(
@@ -200,6 +258,13 @@ module PostProxy
200
258
  { error: response.body }
201
259
  end
202
260
 
261
+ def parse_retry_after(value)
262
+ seconds = Float(value.to_s, exception: false)
263
+ return nil if seconds.nil? || !seconds.finite? || seconds <= 0
264
+
265
+ seconds == seconds.to_i ? seconds.to_i : seconds
266
+ end
267
+
203
268
  def error_message(body)
204
269
  body[:message] || body[:error]
205
270
  end
@@ -2,7 +2,7 @@ module PostProxy
2
2
  DEFAULT_BASE_URL = "https://api.postproxy.dev"
3
3
 
4
4
  PLATFORMS = %w[
5
- facebook instagram tiktok linkedin youtube twitter threads pinterest bluesky telegram google_business
5
+ facebook instagram tiktok linkedin youtube twitter threads pinterest bluesky telegram google_business whatsapp
6
6
  ].freeze
7
7
 
8
8
  PROFILE_STATUSES = %w[active expired inactive].freeze
@@ -16,7 +16,7 @@ module PostProxy
16
16
  INSTAGRAM_FORMATS = %w[post reel story].freeze
17
17
  FACEBOOK_FORMATS = %w[post story reel].freeze
18
18
  TIKTOK_FORMATS = %w[video image].freeze
19
- LINKEDIN_FORMATS = %w[post].freeze
19
+ LINKEDIN_FORMATS = %w[post poll].freeze
20
20
  YOUTUBE_FORMATS = %w[post].freeze
21
21
  PINTEREST_FORMATS = %w[pin].freeze
22
22
  THREADS_FORMATS = %w[post].freeze
@@ -32,6 +32,22 @@ module PostProxy
32
32
 
33
33
  TELEGRAM_PARSE_MODES = %w[HTML MarkdownV2].freeze
34
34
 
35
+ INSTAGRAM_TRIAL_STRATEGIES = %w[MANUAL SS_PERFORMANCE].freeze
36
+
37
+ INSTAGRAM_AUDIO_TYPES = %w[music original_sound].freeze
38
+
39
+ # Facebook and Instagram profiles connect through Facebook login; WhatsApp
40
+ # through the Cloud API or the WhatsApp Business app.
41
+ CONNECTION_ONBOARDINGS = %w[instagram facebook api business_app].freeze
42
+
43
+ DAILY_STATS_ATTRIBUTIONS = %w[received publish].freeze
44
+
45
+ PROFILE_INACTIVE_REASONS = %w[network_taken].freeze
46
+
47
+ POST_SYNC_TRIGGERS = %w[connect scheduled backfill].freeze
48
+
49
+ POST_SYNC_STATUSES = %w[pending running completed failed].freeze
50
+
35
51
  MESSAGE_DIRECTIONS = %w[inbound outbound].freeze
36
52
 
37
53
  MESSAGE_STATUSES = %w[pending published failed_waiting_for_retry failed received].freeze
@@ -42,13 +58,26 @@ module PostProxy
42
58
  platform_post.published
43
59
  platform_post.failed
44
60
  platform_post.failed_waiting_for_retry
61
+ platform_post.edited
62
+ platform_post.edit_failed
45
63
  platform_post.insights
46
64
  profile.connected
47
65
  profile.disconnected
48
66
  profile.stats
67
+ profile.placements_synced
68
+ placement.orphaned
69
+ placement.restored
49
70
  media.failed
50
71
  comment.created
72
+ comment.failed
73
+ comment.failed_waiting_for_retry
74
+ comment.delivery_unconfirmed
75
+ comment.edited
76
+ comment.edit_failed
51
77
  profile_comment.created
78
+ profile_comment.failed
79
+ profile_comment.failed_waiting_for_retry
80
+ profile_comment.deleted
52
81
  message.received
53
82
  message.sent
54
83
  message.delivered
@@ -58,5 +87,11 @@ module PostProxy
58
87
  message.failed_waiting_for_retry
59
88
  message.failed
60
89
  reaction.received
90
+ referral.received
91
+ message_template.status_updated
92
+ message_template.category_updated
93
+ placement.name_status_updated
94
+ placement.quality_updated
95
+ whatsapp.automatic_event
61
96
  ].freeze
62
97
  end
@@ -19,4 +19,19 @@ module PostProxy
19
19
 
20
20
  class ValidationError < Error; end
21
21
  class BadRequestError < Error; end
22
+
23
+ # 403. The account is canceled or past due, or the platform refused the
24
+ # action for lack of permission.
25
+ class ForbiddenError < Error; end
26
+
27
+ # 429. The posting rate limit was reached or the platform is rate-limiting.
28
+ # `retry_after` is the `Retry-After` header in seconds, or nil when absent.
29
+ class RateLimitError < Error
30
+ attr_reader :retry_after
31
+
32
+ def initialize(message, status_code: nil, response: nil, retry_after: nil)
33
+ super(message, status_code: status_code, response: response)
34
+ @retry_after = retry_after
35
+ end
36
+ end
22
37
  end
@@ -0,0 +1,30 @@
1
+ module PostProxy
2
+ module Resources
3
+ # Manage profile-group-scoped API keys. Requires an account-wide key.
4
+ class ApiKeys
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def list
10
+ result = @client.request(:get, "/api_keys")
11
+ keys = (result[:data] || []).map { |k| ApiKey.new(**k) }
12
+ ListResponse.new(data: keys)
13
+ end
14
+
15
+ # The returned `token` is shown only once.
16
+ def create(name, profile_group_id:, idempotency_key: nil)
17
+ result = @client.request(:post, "/api_keys",
18
+ json: { name: name, profile_group_id: profile_group_id },
19
+ idempotency_key: idempotency_key
20
+ )
21
+ ApiKey.new(**result)
22
+ end
23
+
24
+ def delete(id, idempotency_key: nil)
25
+ result = @client.request(:delete, "/api_keys/#{id}", idempotency_key: idempotency_key)
26
+ DeleteResponse.new(**result)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -22,11 +22,14 @@ module PostProxy
22
22
  )
23
23
  end
24
24
 
25
+ # `placement_id` is required for WhatsApp: the phone number id from
26
+ # `profiles.placements`.
25
27
  def create(profile_id, participant_external_id, participant_username: nil, participant_name: nil,
26
- profile_group_id: nil, idempotency_key: nil)
28
+ placement_id: nil, profile_group_id: nil, idempotency_key: nil)
27
29
  json_body = { participant_external_id: participant_external_id }
28
30
  json_body[:participant_username] = participant_username if participant_username
29
31
  json_body[:participant_name] = participant_name if participant_name
32
+ json_body[:placement_id] = placement_id if placement_id
30
33
 
31
34
  result = @client.request(:post, "/profiles/#{profile_id}/chats",
32
35
  json: json_body,
@@ -57,6 +60,32 @@ module PostProxy
57
60
  Chat.new(**result)
58
61
  end
59
62
 
63
+ # Mark the latest inbound message as read (shows the read receipt).
64
+ def mark_read(chat_id, profile_group_id: nil, idempotency_key: nil)
65
+ result = @client.request(:post, "/chats/#{chat_id}/mark_read",
66
+ profile_group_id: profile_group_id,
67
+ idempotency_key: idempotency_key
68
+ )
69
+ Chat.new(**result)
70
+ end
71
+
72
+ # Typing indicator. Facebook, Instagram and WhatsApp only.
73
+ def start_typing(chat_id, profile_group_id: nil, idempotency_key: nil)
74
+ result = @client.request(:post, "/chats/#{chat_id}/start_typing",
75
+ profile_group_id: profile_group_id,
76
+ idempotency_key: idempotency_key
77
+ )
78
+ AcceptedResponse.new(**result)
79
+ end
80
+
81
+ def stop_typing(chat_id, profile_group_id: nil, idempotency_key: nil)
82
+ result = @client.request(:post, "/chats/#{chat_id}/stop_typing",
83
+ profile_group_id: profile_group_id,
84
+ idempotency_key: idempotency_key
85
+ )
86
+ AcceptedResponse.new(**result)
87
+ end
88
+
60
89
  private
61
90
 
62
91
  def format_time(value)
@@ -28,12 +28,13 @@ module PostProxy
28
28
  # Comments across every post in the profile group. Flat: replies come
29
29
  # back as their own entries linked by `parent_external_id`, so `total`
30
30
  # counts every comment. `profiles` takes profile IDs or network names,
31
- # mixed.
32
- def list_all(post_ids: nil, profiles: nil, from: nil, to: nil, page: nil, per_page: nil,
31
+ # mixed. `placement_id` narrows to one page / organization.
32
+ def list_all(post_ids: nil, profiles: nil, placement_id: nil, from: nil, to: nil, page: nil, per_page: nil,
33
33
  profile_group_id: nil)
34
34
  params = {}
35
35
  params[:post_ids] = Array(post_ids).join(",") if post_ids
36
36
  params[:profiles] = Array(profiles).join(",") if profiles
37
+ params[:placement_id] = placement_id if placement_id
37
38
  params[:from] = from if from
38
39
  params[:to] = to if to
39
40
  params[:page] = page if page
@@ -69,6 +70,18 @@ module PostProxy
69
70
  Comment.new(**result)
70
71
  end
71
72
 
73
+ # Edit a comment you published. Facebook and YouTube only. Runs in the
74
+ # background — the outcome arrives as `comment.edited` /
75
+ # `comment.edit_failed`.
76
+ def edit(post_id, comment_id, text, profile_id:, idempotency_key: nil)
77
+ result = @client.request(:patch, "/posts/#{post_id}/comments/#{comment_id}",
78
+ params: { profile_id: profile_id },
79
+ json: { body: text },
80
+ idempotency_key: idempotency_key
81
+ )
82
+ AcceptedResponse.new(**result)
83
+ end
84
+
72
85
  def delete(post_id, comment_id, profile_id:, idempotency_key: nil)
73
86
  result = @client.request(:delete, "/posts/#{post_id}/comments/#{comment_id}",
74
87
  params: { profile_id: profile_id },
@@ -109,14 +122,49 @@ module PostProxy
109
122
  AcceptedResponse.new(**result)
110
123
  end
111
124
 
112
- def private_reply(post_id, comment_id, profile_id:, text:, idempotency_key: nil)
125
+ # TikTok only.
126
+ def pin(post_id, comment_id, profile_id:, idempotency_key: nil)
127
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/pin",
128
+ params: { profile_id: profile_id },
129
+ idempotency_key: idempotency_key
130
+ )
131
+ AcceptedResponse.new(**result)
132
+ end
133
+
134
+ def unpin(post_id, comment_id, profile_id:, idempotency_key: nil)
135
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/unpin",
136
+ params: { profile_id: profile_id },
137
+ idempotency_key: idempotency_key
138
+ )
139
+ AcceptedResponse.new(**result)
140
+ end
141
+
142
+ # Sends a DM to the comment's author (Instagram/Facebook) and returns a
143
+ # Message. One private reply per comment, within 7 days of it.
144
+ # `quick_replies`, `buttons`, `card` and `elements` work as in
145
+ # Messages#send_message (model instances or plain hashes); `text` may be
146
+ # empty when sending `elements`.
147
+ def private_reply(post_id, comment_id, profile_id:, text:, quick_replies: nil, buttons: nil, card: nil,
148
+ elements: nil, idempotency_key: nil)
149
+ json_body = { text: text }
150
+ json_body[:quick_replies] = quick_replies.map { |q| serialize_interactive(q) } if quick_replies
151
+ json_body[:buttons] = buttons.map { |b| serialize_interactive(b) } if buttons
152
+ json_body[:card] = serialize_interactive(card) if card
153
+ json_body[:elements] = elements.map { |e| serialize_interactive(e) } if elements
154
+
113
155
  result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/private_reply",
114
156
  params: { profile_id: profile_id },
115
- json: { text: text },
157
+ json: json_body,
116
158
  idempotency_key: idempotency_key
117
159
  )
118
160
  Message.new(**result)
119
161
  end
162
+
163
+ private
164
+
165
+ def serialize_interactive(value)
166
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
167
+ end
120
168
  end
121
169
  end
122
170
  end
@@ -0,0 +1,194 @@
1
+ module PostProxy
2
+ module Resources
3
+ # Manages a Google Business Profile location's own data — hours,
4
+ # attributes, services, menus, action links and photos — separate from
5
+ # posting to it.
6
+ #
7
+ # `location_id` is the placement id from `profiles.placements(profile_id)`.
8
+ # Google's payloads are camelCased and passed through untouched, so you can
9
+ # PATCH back exactly what you GET; responses are returned as plain hashes.
10
+ # Updates take a `fields` list naming which top-level fields to change
11
+ # (Google's update mask).
12
+ class GoogleBusiness
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ # --- Location ---
18
+
19
+ def location(profile_id, location_id, read_mask: nil, profile_group_id: nil)
20
+ read(profile_id, "location", location_id, { read_mask: read_mask }, profile_group_id)
21
+ end
22
+
23
+ # `location` holds the Google fields to change (e.g. `title`,
24
+ # `phoneNumbers`, `websiteUri`); `fields` names them.
25
+ def update_location(profile_id, location_id, location, fields:, profile_group_id: nil, idempotency_key: nil)
26
+ write(:patch, profile_id, "update_location", location_id,
27
+ location.merge(fields: fields), profile_group_id, idempotency_key)
28
+ end
29
+
30
+ def available_location_categories(profile_id, location_id, region_code, language_code: nil, filter: nil,
31
+ view: nil, page_size: nil, page_token: nil, profile_group_id: nil)
32
+ read(profile_id, "available_location_categories", location_id, {
33
+ region_code: region_code,
34
+ language_code: language_code,
35
+ filter: filter,
36
+ view: view,
37
+ page_size: page_size,
38
+ page_token: page_token
39
+ }, profile_group_id)
40
+ end
41
+
42
+ def update_hours(profile_id, location_id, fields: nil, regular_hours: nil, special_hours: nil,
43
+ more_hours: nil, profile_group_id: nil, idempotency_key: nil)
44
+ write(:patch, profile_id, "update_hours", location_id, {
45
+ fields: fields,
46
+ regularHours: regular_hours,
47
+ specialHours: special_hours,
48
+ moreHours: more_hours
49
+ }, profile_group_id, idempotency_key)
50
+ end
51
+
52
+ # --- Attributes ---
53
+
54
+ def attributes(profile_id, location_id, profile_group_id: nil)
55
+ read(profile_id, "attributes", location_id, {}, profile_group_id)
56
+ end
57
+
58
+ def available_attributes(profile_id, location_id, category_name: nil, region_code: nil, language_code: nil,
59
+ show_all: nil, page_size: nil, page_token: nil, profile_group_id: nil)
60
+ read(profile_id, "available_attributes", location_id, {
61
+ category_name: category_name,
62
+ region_code: region_code,
63
+ language_code: language_code,
64
+ show_all: show_all,
65
+ page_size: page_size,
66
+ page_token: page_token
67
+ }, profile_group_id)
68
+ end
69
+
70
+ def update_attributes(profile_id, location_id, attributes, attribute_mask: nil, profile_group_id: nil,
71
+ idempotency_key: nil)
72
+ write(:patch, profile_id, "update_attributes", location_id,
73
+ { attributes: attributes, attribute_mask: attribute_mask }, profile_group_id, idempotency_key)
74
+ end
75
+
76
+ # --- Services & food menus ---
77
+
78
+ def service_list(profile_id, location_id, profile_group_id: nil)
79
+ read(profile_id, "service_list", location_id, {}, profile_group_id)
80
+ end
81
+
82
+ def update_service_list(profile_id, location_id, service_items, fields: nil, profile_group_id: nil,
83
+ idempotency_key: nil)
84
+ write(:patch, profile_id, "update_service_list", location_id,
85
+ { serviceItems: service_items, fields: fields }, profile_group_id, idempotency_key)
86
+ end
87
+
88
+ def food_menus(profile_id, location_id, fields: nil, profile_group_id: nil)
89
+ read(profile_id, "food_menus", location_id, { fields: fields && Array(fields).join(",") }, profile_group_id)
90
+ end
91
+
92
+ def update_food_menus(profile_id, location_id, menus, fields: nil, profile_group_id: nil,
93
+ idempotency_key: nil)
94
+ write(:patch, profile_id, "update_food_menus", location_id,
95
+ { menus: menus, fields: fields }, profile_group_id, idempotency_key)
96
+ end
97
+
98
+ # --- Place action links (booking, ordering, ...) ---
99
+
100
+ def place_action_links(profile_id, location_id, page_size: nil, page_token: nil, profile_group_id: nil)
101
+ read(profile_id, "place_action_links", location_id,
102
+ { page_size: page_size, page_token: page_token }, profile_group_id)
103
+ end
104
+
105
+ def available_place_action_types(profile_id, location_id, language_code: nil, page_size: nil,
106
+ page_token: nil, profile_group_id: nil)
107
+ read(profile_id, "available_place_action_types", location_id, {
108
+ language_code: language_code,
109
+ page_size: page_size,
110
+ page_token: page_token
111
+ }, profile_group_id)
112
+ end
113
+
114
+ # `place_action_type` is e.g. APPOINTMENT, ONLINE_APPOINTMENT,
115
+ # DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT,
116
+ # SHOP_ONLINE.
117
+ def create_place_action_link(profile_id, location_id, uri:, place_action_type:, is_preferred: nil,
118
+ profile_group_id: nil, idempotency_key: nil)
119
+ write(:post, profile_id, "create_place_action_link", location_id, {
120
+ uri: uri,
121
+ place_action_type: place_action_type,
122
+ is_preferred: is_preferred
123
+ }, profile_group_id, idempotency_key)
124
+ end
125
+
126
+ # `name` is the link's resource name from #place_action_links.
127
+ def update_place_action_link(profile_id, location_id, name, fields:, uri: nil, place_action_type: nil,
128
+ is_preferred: nil, profile_group_id: nil, idempotency_key: nil)
129
+ write(:patch, profile_id, "update_place_action_link", location_id, {
130
+ name: name,
131
+ fields: fields,
132
+ uri: uri,
133
+ place_action_type: place_action_type,
134
+ is_preferred: is_preferred
135
+ }, profile_group_id, idempotency_key)
136
+ end
137
+
138
+ def delete_place_action_link(profile_id, location_id, name, profile_group_id: nil, idempotency_key: nil)
139
+ remove(profile_id, "delete_place_action_link", location_id, { name: name }, profile_group_id, idempotency_key)
140
+ end
141
+
142
+ # --- Media ---
143
+
144
+ def media(profile_id, location_id, page_size: nil, page_token: nil, profile_group_id: nil)
145
+ read(profile_id, "media", location_id, { page_size: page_size, page_token: page_token }, profile_group_id)
146
+ end
147
+
148
+ # `category` is e.g. COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT,
149
+ # ADDITIONAL; `media_format` is PHOTO or VIDEO.
150
+ def create_media(profile_id, location_id, media_url, category: nil, media_format: nil, description: nil,
151
+ profile_group_id: nil, idempotency_key: nil)
152
+ write(:post, profile_id, "create_media", location_id, {
153
+ media_url: media_url,
154
+ category: category,
155
+ media_format: media_format,
156
+ description: description
157
+ }, profile_group_id, idempotency_key)
158
+ end
159
+
160
+ # `media_name` is the item's resource name from #media.
161
+ def delete_media(profile_id, location_id, media_name, profile_group_id: nil, idempotency_key: nil)
162
+ remove(profile_id, "delete_media", location_id, { media_name: media_name }, profile_group_id, idempotency_key)
163
+ end
164
+
165
+ private
166
+
167
+ def path(profile_id, action)
168
+ "/profiles/#{profile_id}/google_business/#{action}"
169
+ end
170
+
171
+ def read(profile_id, action, location_id, query, profile_group_id)
172
+ params = { location_id: location_id }.merge(query.compact)
173
+ @client.request(:get, path(profile_id, action), params: params, profile_group_id: profile_group_id)
174
+ end
175
+
176
+ def write(method, profile_id, action, location_id, body, profile_group_id, idempotency_key)
177
+ json_body = { location_id: location_id }.merge(body.compact)
178
+ @client.request(method, path(profile_id, action),
179
+ json: json_body,
180
+ profile_group_id: profile_group_id,
181
+ idempotency_key: idempotency_key
182
+ )
183
+ end
184
+
185
+ def remove(profile_id, action, location_id, query, profile_group_id, idempotency_key)
186
+ @client.request(:delete, path(profile_id, action),
187
+ params: { location_id: location_id }.merge(query),
188
+ profile_group_id: profile_group_id,
189
+ idempotency_key: idempotency_key
190
+ )
191
+ end
192
+ end
193
+ end
194
+ end
@@ -22,9 +22,28 @@ module PostProxy
22
22
  )
23
23
  end
24
24
 
25
+ # quick_replies, buttons, and card are Facebook and Instagram only — they
26
+ # return 422 on Telegram and Bluesky, where reply_markup is the
27
+ # equivalent. They are sent on the JSON path only, so pass media as hosted
28
+ # URLs rather than media_files when combining with an attachment.
29
+ #
30
+ # Each accepts model instances or plain hashes.
31
+ #
32
+ # `elements` sends a carousel of up to 10 cards (Facebook and Instagram);
33
+ # it can't be combined with body, buttons or card.
34
+ #
35
+ # WhatsApp: send exactly one of body, media, `template`, `interactive`,
36
+ # `location` or `contacts`. Outside the 24-hour window only a `template`
37
+ # gets through. `category: "utility"` sends a text message under the
38
+ # utility category; `link_preview` toggles URL previews, `voice_note`
39
+ # delivers an audio attachment as a voice note, and `filename` names a
40
+ # document attachment.
25
41
  def send_message(chat_id, body: nil, media: nil, media_files: nil, tag: nil,
26
- reply_to_external_id: nil, reply_markup: nil, profile_group_id: nil,
27
- idempotency_key: nil)
42
+ reply_to_external_id: nil, reply_markup: nil,
43
+ quick_replies: nil, buttons: nil, card: nil, elements: nil,
44
+ template: nil, interactive: nil, location: nil, contacts: nil,
45
+ category: nil, link_preview: nil, voice_note: nil, filename: nil,
46
+ profile_group_id: nil, idempotency_key: nil)
28
47
  has_files = media_files && !media_files.empty?
29
48
 
30
49
  if has_files
@@ -32,6 +51,8 @@ module PostProxy
32
51
  form_data["body"] = body if body
33
52
  form_data["tag"] = tag if tag
34
53
  form_data["reply_to_external_id"] = reply_to_external_id if reply_to_external_id
54
+ form_data["voice_note"] = voice_note.to_s unless voice_note.nil?
55
+ form_data["filename"] = filename if filename
35
56
 
36
57
  files = []
37
58
  media&.each do |m|
@@ -58,6 +79,18 @@ module PostProxy
58
79
  json_body[:tag] = tag if tag
59
80
  json_body[:reply_to_external_id] = reply_to_external_id if reply_to_external_id
60
81
  json_body[:reply_markup] = reply_markup if reply_markup
82
+ json_body[:quick_replies] = quick_replies.map { |q| serialize_interactive(q) } if quick_replies
83
+ json_body[:buttons] = buttons.map { |b| serialize_interactive(b) } if buttons
84
+ json_body[:card] = serialize_interactive(card) if card
85
+ json_body[:elements] = elements.map { |e| serialize_interactive(e) } if elements
86
+ json_body[:template] = serialize_interactive(template) if template
87
+ json_body[:interactive] = interactive if interactive
88
+ json_body[:location] = serialize_interactive(location) if location
89
+ json_body[:contacts] = contacts if contacts
90
+ json_body[:category] = category if category
91
+ json_body[:link_preview] = link_preview unless link_preview.nil?
92
+ json_body[:voice_note] = voice_note unless voice_note.nil?
93
+ json_body[:filename] = filename if filename
61
94
 
62
95
  result = @client.request(:post, "/chats/#{chat_id}/messages",
63
96
  json: json_body,
@@ -111,6 +144,13 @@ module PostProxy
111
144
 
112
145
  private
113
146
 
147
+ # Interactive params accept model instances or plain hashes. Models expose
148
+ # to_h with nils dropped, so an omitted content_type stays omitted rather
149
+ # than being sent as null.
150
+ def serialize_interactive(value)
151
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
152
+ end
153
+
114
154
  def mime_type_for(filename)
115
155
  case File.extname(filename).downcase
116
156
  when ".jpg", ".jpeg" then "image/jpeg"
@@ -124,6 +164,7 @@ module PostProxy
124
164
  when ".mp3" then "audio/mpeg"
125
165
  when ".ogg" then "audio/ogg"
126
166
  when ".m4a" then "audio/mp4"
167
+ when ".pdf" then "application/pdf"
127
168
  else "application/octet-stream"
128
169
  end
129
170
  end