postproxy-sdk 1.13.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.
- checksums.yaml +4 -4
- data/README.md +415 -11
- data/lib/postproxy/client.rb +72 -7
- data/lib/postproxy/constants.rb +37 -2
- data/lib/postproxy/errors.rb +15 -0
- data/lib/postproxy/resources/api_keys.rb +30 -0
- data/lib/postproxy/resources/chats.rb +30 -1
- data/lib/postproxy/resources/comments.rb +52 -4
- data/lib/postproxy/resources/google_business.rb +194 -0
- data/lib/postproxy/resources/messages.rb +25 -1
- data/lib/postproxy/resources/placements.rb +35 -0
- data/lib/postproxy/resources/posts.rb +86 -25
- data/lib/postproxy/resources/profile_groups.rb +8 -1
- data/lib/postproxy/resources/profiles.rb +131 -0
- data/lib/postproxy/resources/whatsapp.rb +278 -0
- data/lib/postproxy/resources/whatsapp_templates.rb +108 -0
- data/lib/postproxy/types.rb +408 -29
- data/lib/postproxy/version.rb +1 -1
- data/lib/postproxy/webhook_events.rb +90 -4
- metadata +9 -4
data/lib/postproxy/client.rb
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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] =
|
|
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
|
data/lib/postproxy/constants.rb
CHANGED
|
@@ -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
|
data/lib/postproxy/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
@@ -28,9 +28,21 @@ module PostProxy
|
|
|
28
28
|
# URLs rather than media_files when combining with an attachment.
|
|
29
29
|
#
|
|
30
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.
|
|
31
41
|
def send_message(chat_id, body: nil, media: nil, media_files: nil, tag: nil,
|
|
32
42
|
reply_to_external_id: nil, reply_markup: nil,
|
|
33
|
-
quick_replies: nil, buttons: nil, card: 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,
|
|
34
46
|
profile_group_id: nil, idempotency_key: nil)
|
|
35
47
|
has_files = media_files && !media_files.empty?
|
|
36
48
|
|
|
@@ -39,6 +51,8 @@ module PostProxy
|
|
|
39
51
|
form_data["body"] = body if body
|
|
40
52
|
form_data["tag"] = tag if tag
|
|
41
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
|
|
42
56
|
|
|
43
57
|
files = []
|
|
44
58
|
media&.each do |m|
|
|
@@ -68,6 +82,15 @@ module PostProxy
|
|
|
68
82
|
json_body[:quick_replies] = quick_replies.map { |q| serialize_interactive(q) } if quick_replies
|
|
69
83
|
json_body[:buttons] = buttons.map { |b| serialize_interactive(b) } if buttons
|
|
70
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
|
|
71
94
|
|
|
72
95
|
result = @client.request(:post, "/chats/#{chat_id}/messages",
|
|
73
96
|
json: json_body,
|
|
@@ -141,6 +164,7 @@ module PostProxy
|
|
|
141
164
|
when ".mp3" then "audio/mpeg"
|
|
142
165
|
when ".ogg" then "audio/ogg"
|
|
143
166
|
when ".m4a" then "audio/mp4"
|
|
167
|
+
when ".pdf" then "application/pdf"
|
|
144
168
|
else "application/octet-stream"
|
|
145
169
|
end
|
|
146
170
|
end
|
|
@@ -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
|