fopost 0.1.0 → 0.3.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/CHANGELOG.md +181 -0
- data/README.md +645 -2
- data/lib/fopost/client.rb +33 -1
- data/lib/fopost/errors.rb +25 -0
- data/lib/fopost/file_part.rb +73 -0
- data/lib/fopost/http/client.rb +36 -4
- data/lib/fopost/http/multipart.rb +38 -0
- data/lib/fopost/models.rb +2104 -0
- data/lib/fopost/platforms.rb +27 -0
- data/lib/fopost/resources/account_groups.rb +37 -0
- data/lib/fopost/resources/accounts.rb +445 -3
- data/lib/fopost/resources/activity.rb +27 -0
- data/lib/fopost/resources/ads.rb +1097 -0
- data/lib/fopost/resources/analytics.rb +63 -0
- data/lib/fopost/resources/blog.rb +206 -0
- data/lib/fopost/resources/blogs.rb +115 -0
- data/lib/fopost/resources/broadcasts.rb +189 -0
- data/lib/fopost/resources/contacts.rb +136 -0
- data/lib/fopost/resources/google_ads.rb +346 -0
- data/lib/fopost/resources/google_business.rb +166 -0
- data/lib/fopost/resources/inbox.rb +224 -0
- data/lib/fopost/resources/knowledge.rb +74 -0
- data/lib/fopost/resources/media.rb +56 -0
- data/lib/fopost/resources/posts.rb +75 -4
- data/lib/fopost/resources/validate.rb +37 -0
- data/lib/fopost/resources/whatsapp.rb +255 -0
- data/lib/fopost/version.rb +1 -1
- data/lib/fopost/webhooks.rb +97 -0
- data/lib/fopost.rb +2 -0
- metadata +19 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.contacts` — the people behind the inbox, and the fields your
|
|
6
|
+
# workspace keeps about them.
|
|
7
|
+
#
|
|
8
|
+
# A contact is one human however many handles they write from. An inbound
|
|
9
|
+
# inbox item files its author, a reply files whoever you answered, and both
|
|
10
|
+
# fold into whatever is already on file. Every method needs the `inbox`
|
|
11
|
+
# scope, except {#conversation_analytics}, which needs `analytics`.
|
|
12
|
+
class Contacts < Base
|
|
13
|
+
# One page of contacts, most recently active first. The result is
|
|
14
|
+
# Enumerable over its items.
|
|
15
|
+
#
|
|
16
|
+
# Omit `workspace_id` to span every workspace the key can reach; each
|
|
17
|
+
# contact then carries `workspace_id`.
|
|
18
|
+
def list(workspace_id: nil, search: nil, platform: nil, source: nil, page: 1, per_page: 25)
|
|
19
|
+
body = http.get(
|
|
20
|
+
'/contacts',
|
|
21
|
+
{
|
|
22
|
+
'workspace_id' => workspace_id,
|
|
23
|
+
'search' => search,
|
|
24
|
+
'platform' => platform,
|
|
25
|
+
'source' => source,
|
|
26
|
+
'page' => page,
|
|
27
|
+
'per_page' => per_page
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
hash = as_hash(body)
|
|
31
|
+
raw_meta = hash['pagination']
|
|
32
|
+
Page.new(
|
|
33
|
+
items: parse_list(Contact, hash['data']),
|
|
34
|
+
meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get(contact_id)
|
|
39
|
+
Contact.new(unwrap(http.get("/contacts/#{contact_id}")))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create a contact.
|
|
43
|
+
#
|
|
44
|
+
# Folds into the contact that already holds the first channel, so this
|
|
45
|
+
# cannot duplicate someone the inbox has already met.
|
|
46
|
+
def create(workspace_id:, channels:, display_name: nil, note: nil, fields: nil)
|
|
47
|
+
body = compact_nil(
|
|
48
|
+
'workspace_id' => workspace_id,
|
|
49
|
+
'channels' => channels,
|
|
50
|
+
'display_name' => display_name,
|
|
51
|
+
'note' => note,
|
|
52
|
+
'fields' => fields
|
|
53
|
+
)
|
|
54
|
+
Contact.new(unwrap(http.post('/contacts', body)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Partial update: only what you pass is sent. A custom field set to nil
|
|
58
|
+
# is cleared.
|
|
59
|
+
def update(contact_id, display_name: UNSET, channels: UNSET, note: UNSET, fields: UNSET)
|
|
60
|
+
body = compact_unset(
|
|
61
|
+
'display_name' => display_name,
|
|
62
|
+
'channels' => channels,
|
|
63
|
+
'note' => note,
|
|
64
|
+
'fields' => fields
|
|
65
|
+
)
|
|
66
|
+
Contact.new(unwrap(http.request(:patch, "/contacts/#{contact_id}", json: body)))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Removes a contact. Their messages stay in the inbox and file them again.
|
|
70
|
+
def delete(contact_id)
|
|
71
|
+
http.delete("/contacts/#{contact_id}")
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The threads this person appears in, newest first.
|
|
76
|
+
def conversations(contact_id, limit: nil)
|
|
77
|
+
parse_list(
|
|
78
|
+
ContactConversation,
|
|
79
|
+
unwrap(http.get("/contacts/#{contact_id}/conversations", { 'limit' => limit }))
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Import from CSV text.
|
|
84
|
+
#
|
|
85
|
+
# `platform` and `handle` are required columns. Any other column is read
|
|
86
|
+
# as a custom field key, and one matching no field comes back in
|
|
87
|
+
# `unknown_columns` rather than being stored.
|
|
88
|
+
def import(workspace_id:, csv:)
|
|
89
|
+
ContactImportResult.new(
|
|
90
|
+
unwrap(http.post('/contacts/import', { 'workspace_id' => workspace_id, 'csv' => csv }))
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The columns this workspace keeps about its contacts, in display order.
|
|
95
|
+
def list_fields(workspace_id)
|
|
96
|
+
parse_list(ContactField, unwrap(http.get('/contacts/fields', { 'workspace_id' => workspace_id })))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_field(workspace_id:, key:, name:, type: 'text', options: [])
|
|
100
|
+
ContactField.new(unwrap(http.request(
|
|
101
|
+
:post,
|
|
102
|
+
'/contacts/fields',
|
|
103
|
+
json: { 'key' => key, 'name' => name, 'type' => type, 'options' => options },
|
|
104
|
+
params: { 'workspace_id' => workspace_id }
|
|
105
|
+
)))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The key and the type are fixed once created; the name and options are not.
|
|
109
|
+
def update_field(field_id, name: UNSET, options: UNSET, position: UNSET)
|
|
110
|
+
body = compact_unset('name' => name, 'options' => options, 'position' => position)
|
|
111
|
+
ContactField.new(unwrap(http.request(:patch, "/contacts/fields/#{field_id}", json: body)))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Removes the field and every answer to it.
|
|
115
|
+
def delete_field(field_id)
|
|
116
|
+
http.delete("/contacts/fields/#{field_id}")
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Volume and median reply time per thread. Needs the `analytics` scope.
|
|
121
|
+
def conversation_analytics(workspace_id: nil, account_id: nil, days: nil, sort: nil, page: 1, per_page: 25)
|
|
122
|
+
ConversationAnalytics.new(unwrap(http.get(
|
|
123
|
+
'/analytics/inbox/conversations',
|
|
124
|
+
{
|
|
125
|
+
'workspace_id' => workspace_id,
|
|
126
|
+
'accountId' => account_id,
|
|
127
|
+
'days' => days,
|
|
128
|
+
'sort' => sort,
|
|
129
|
+
'page' => page,
|
|
130
|
+
'per_page' => per_page
|
|
131
|
+
}
|
|
132
|
+
)))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.ads.google` — the Google Ads surface no other network has.
|
|
6
|
+
#
|
|
7
|
+
# Campaigns, ad groups, ads, audiences and insights are on `client.ads`
|
|
8
|
+
# and dispatch by connection. What is here — keywords, assets, Performance
|
|
9
|
+
# Max asset groups, Local Services leads, conversions and raw GAQL — is
|
|
10
|
+
# Google only, and a connection on another network answers 400.
|
|
11
|
+
#
|
|
12
|
+
# Every method needs the `ads` scope; anything that changes what a live
|
|
13
|
+
# account serves or bids also needs `publish`. `customer_id` is digits only
|
|
14
|
+
# and has to name an account the connection's grant reaches.
|
|
15
|
+
class GoogleAds < Base
|
|
16
|
+
# ── Keywords ──
|
|
17
|
+
|
|
18
|
+
# Keywords on the account, or on one ad group.
|
|
19
|
+
def keywords(connection_id:, customer_id:, workspace_id: nil, ad_group_id: nil)
|
|
20
|
+
parse_list(GoogleKeyword, unwrap(http.get(
|
|
21
|
+
'/ads/google/keywords',
|
|
22
|
+
params(connection_id, customer_id, workspace_id,
|
|
23
|
+
'ad_group_id' => ad_group_id)
|
|
24
|
+
)))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The new keyword's id. Needs `publish` as well as `ads`.
|
|
28
|
+
def create_keyword(workspace_id:, connection_id:, customer_id:, ad_group_id:, text:, match_type:,
|
|
29
|
+
cpc_bid_minor: nil)
|
|
30
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
31
|
+
'adGroupId' => ad_group_id,
|
|
32
|
+
'text' => text,
|
|
33
|
+
'matchType' => match_type,
|
|
34
|
+
'cpcBidMinor' => cpc_bid_minor
|
|
35
|
+
))
|
|
36
|
+
id_of(http.post('/ads/google/keywords', body))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `status` is "active" or "paused". Needs `publish` as well as `ads`.
|
|
40
|
+
def update_keyword(keyword_id, workspace_id:, connection_id:, customer_id:, status: nil, cpc_bid_minor: nil)
|
|
41
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
42
|
+
'status' => status,
|
|
43
|
+
'cpcBidMinor' => cpc_bid_minor
|
|
44
|
+
))
|
|
45
|
+
id_of(http.request(:patch, "/ads/google/keywords/#{keyword_id}", json: body))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Needs `publish` as well as `ads`.
|
|
49
|
+
def delete_keyword(keyword_id, workspace_id:, connection_id:, customer_id:)
|
|
50
|
+
http.request(:delete, "/ads/google/keywords/#{keyword_id}",
|
|
51
|
+
json: scope(workspace_id, connection_id, customer_id))
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Ideas from seed keywords, a landing page, or both.
|
|
56
|
+
def keyword_ideas(workspace_id:, connection_id:, customer_id:, seeds: nil, url: nil, language_id: nil,
|
|
57
|
+
geo_target_ids: nil)
|
|
58
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
59
|
+
'seeds' => seeds,
|
|
60
|
+
'url' => url,
|
|
61
|
+
'languageId' => language_id,
|
|
62
|
+
'geoTargetIds' => geo_target_ids
|
|
63
|
+
))
|
|
64
|
+
parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-ideas', body)))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def keyword_metrics(workspace_id:, connection_id:, customer_id:, keywords:)
|
|
68
|
+
body = scope(workspace_id, connection_id, customer_id).merge('keywords' => keywords)
|
|
69
|
+
parse_list(GoogleKeywordIdea, unwrap(http.post('/ads/google/keyword-metrics', body)))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# What people actually searched, with the metrics each term earned.
|
|
73
|
+
def search_terms(connection_id:, customer_id:, since:, until_date:, workspace_id: nil)
|
|
74
|
+
parse_list(GoogleSearchTerm, unwrap(http.get(
|
|
75
|
+
'/ads/google/search-terms',
|
|
76
|
+
params(connection_id, customer_id, workspace_id,
|
|
77
|
+
'since' => since, 'until' => until_date)
|
|
78
|
+
)))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ── Bid strategies and ad schedule ──
|
|
82
|
+
|
|
83
|
+
def bid_strategies(connection_id:, customer_id:, workspace_id: nil)
|
|
84
|
+
parse_list(GoogleBidStrategy, unwrap(http.get(
|
|
85
|
+
'/ads/google/bid-strategies',
|
|
86
|
+
params(connection_id, customer_id, workspace_id)
|
|
87
|
+
)))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Needs `publish` as well as `ads`.
|
|
91
|
+
def create_bid_strategy(workspace_id:, connection_id:, customer_id:, name:, type:, target_minor: nil)
|
|
92
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
93
|
+
'name' => name,
|
|
94
|
+
'type' => type,
|
|
95
|
+
'targetMinor' => target_minor
|
|
96
|
+
))
|
|
97
|
+
id_of(http.post('/ads/google/bid-strategies', body))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def ad_schedule(connection_id:, customer_id:, campaign_id:, workspace_id: nil)
|
|
101
|
+
parse_list(GoogleAdScheduleSlot, unwrap(http.get(
|
|
102
|
+
'/ads/google/ad-schedule',
|
|
103
|
+
params(connection_id, customer_id, workspace_id,
|
|
104
|
+
'campaign_id' => campaign_id)
|
|
105
|
+
)))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Replaces every slot on the campaign: Google has no partial edit for a
|
|
109
|
+
# schedule. Needs `publish` as well as `ads`.
|
|
110
|
+
def set_ad_schedule(workspace_id:, connection_id:, customer_id:, campaign_id:, slots:)
|
|
111
|
+
body = scope(workspace_id, connection_id, customer_id).merge('campaignId' => campaign_id, 'slots' => slots)
|
|
112
|
+
result = unwrap(http.request(:put, '/ads/google/ad-schedule', json: body))
|
|
113
|
+
result.is_a?(Hash) ? result['slots'].to_i : 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ── Negative keyword lists ──
|
|
117
|
+
|
|
118
|
+
def negative_keyword_lists(connection_id:, customer_id:, workspace_id: nil)
|
|
119
|
+
parse_list(GoogleSharedSet, unwrap(http.get(
|
|
120
|
+
'/ads/google/negative-keywords',
|
|
121
|
+
params(connection_id, customer_id, workspace_id)
|
|
122
|
+
)))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Needs `publish` as well as `ads`.
|
|
126
|
+
def create_negative_keyword_list(workspace_id:, connection_id:, customer_id:, name:)
|
|
127
|
+
body = scope(workspace_id, connection_id, customer_id).merge('name' => name)
|
|
128
|
+
id_of(http.post('/ads/google/negative-keywords', body))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# How many were added. Needs `publish` as well as `ads`.
|
|
132
|
+
def add_negative_keywords(workspace_id:, connection_id:, customer_id:, shared_set_id:, keywords:)
|
|
133
|
+
body = scope(workspace_id, connection_id, customer_id).merge(
|
|
134
|
+
'sharedSetId' => shared_set_id, 'keywords' => keywords
|
|
135
|
+
)
|
|
136
|
+
result = unwrap(http.post('/ads/google/negative-keywords/keywords', body))
|
|
137
|
+
result.is_a?(Hash) ? result['added'].to_i : 0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Needs `publish` as well as `ads`.
|
|
141
|
+
def attach_negative_keyword_list(workspace_id:, connection_id:, customer_id:, shared_set_id:, campaign_id:)
|
|
142
|
+
body = scope(workspace_id, connection_id, customer_id).merge(
|
|
143
|
+
'sharedSetId' => shared_set_id, 'campaignId' => campaign_id
|
|
144
|
+
)
|
|
145
|
+
http.post('/ads/google/negative-keywords/attach', body)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── Assets ──
|
|
150
|
+
|
|
151
|
+
# Returns `{ assets: [...], links: [...] }`; an asset with no links is in
|
|
152
|
+
# the library and serving nowhere.
|
|
153
|
+
def assets(connection_id:, customer_id:, workspace_id: nil)
|
|
154
|
+
result = unwrap(http.get('/ads/google/assets', params(connection_id, customer_id, workspace_id)))
|
|
155
|
+
result = {} unless result.is_a?(Hash)
|
|
156
|
+
{
|
|
157
|
+
assets: parse_list(GoogleAsset, result['assets']),
|
|
158
|
+
links: parse_list(GoogleAssetLink, result['links'])
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `spec` is a sitelink, callout or snippet. Needs `publish` as well as `ads`.
|
|
163
|
+
def create_asset(workspace_id:, connection_id:, customer_id:, spec:)
|
|
164
|
+
body = scope(workspace_id, connection_id, customer_id).merge('spec' => spec)
|
|
165
|
+
id_of(http.post('/ads/google/assets', body))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Attaches to the account when `campaign_id` is left out. Needs `publish`.
|
|
169
|
+
def attach_asset(workspace_id:, connection_id:, customer_id:, asset_id:, field_type:, campaign_id: nil)
|
|
170
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
171
|
+
'assetId' => asset_id,
|
|
172
|
+
'fieldType' => field_type,
|
|
173
|
+
'campaignId' => campaign_id
|
|
174
|
+
))
|
|
175
|
+
http.post('/ads/google/assets/attach', body)
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Removes the links that put it under an ad; on Google the asset itself
|
|
180
|
+
# is permanent. Needs `publish` as well as `ads`.
|
|
181
|
+
def delete_asset(asset_id, workspace_id:, connection_id:, customer_id:)
|
|
182
|
+
http.request(:delete, "/ads/google/assets/#{asset_id}",
|
|
183
|
+
json: scope(workspace_id, connection_id, customer_id))
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ── Performance Max asset groups ──
|
|
188
|
+
|
|
189
|
+
def asset_groups(connection_id:, customer_id:, workspace_id: nil, campaign_id: nil)
|
|
190
|
+
parse_list(GoogleAssetGroup, unwrap(http.get(
|
|
191
|
+
'/ads/google/asset-groups',
|
|
192
|
+
params(connection_id, customer_id, workspace_id,
|
|
193
|
+
'campaign_id' => campaign_id)
|
|
194
|
+
)))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Starts paused unless `status` says otherwise. Needs `publish`.
|
|
198
|
+
def create_asset_group(workspace_id:, connection_id:, customer_id:, campaign_id:, name:, final_urls:,
|
|
199
|
+
status: nil)
|
|
200
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
201
|
+
'campaignId' => campaign_id,
|
|
202
|
+
'name' => name,
|
|
203
|
+
'finalUrls' => final_urls,
|
|
204
|
+
'status' => status
|
|
205
|
+
))
|
|
206
|
+
id_of(http.post('/ads/google/asset-groups', body))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Needs `publish` as well as `ads`.
|
|
210
|
+
def update_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:, name: nil, status: nil)
|
|
211
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
212
|
+
'name' => name,
|
|
213
|
+
'status' => status
|
|
214
|
+
))
|
|
215
|
+
id_of(http.request(:patch, "/ads/google/asset-groups/#{asset_group_id}", json: body))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Needs `publish` as well as `ads`.
|
|
219
|
+
def delete_asset_group(asset_group_id, workspace_id:, connection_id:, customer_id:)
|
|
220
|
+
http.request(:delete, "/ads/google/asset-groups/#{asset_group_id}",
|
|
221
|
+
json: scope(workspace_id, connection_id, customer_id))
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# ── Local Services leads ──
|
|
226
|
+
|
|
227
|
+
# Read live on every call and never stored by FoPost.
|
|
228
|
+
def local_services_leads(connection_id:, customer_id:, since:, until_date:, workspace_id: nil)
|
|
229
|
+
parse_list(GoogleLocalServicesLead, unwrap(http.get(
|
|
230
|
+
'/ads/google/local-services',
|
|
231
|
+
params(connection_id, customer_id, workspace_id,
|
|
232
|
+
'since' => since, 'until' => until_date)
|
|
233
|
+
)))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ── Conversions ──
|
|
237
|
+
|
|
238
|
+
def conversion_actions(connection_id:, customer_id:, workspace_id: nil)
|
|
239
|
+
parse_list(GoogleConversionAction, unwrap(http.get(
|
|
240
|
+
'/ads/google/conversions',
|
|
241
|
+
params(connection_id, customer_id, workspace_id)
|
|
242
|
+
)))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Needs `publish` as well as `ads`.
|
|
246
|
+
def create_conversion_action(workspace_id:, connection_id:, customer_id:, name:, category:, value_minor: nil,
|
|
247
|
+
counting_type: nil)
|
|
248
|
+
body = scope(workspace_id, connection_id, customer_id).merge(compact_nil(
|
|
249
|
+
'name' => name,
|
|
250
|
+
'category' => category,
|
|
251
|
+
'valueMinor' => value_minor,
|
|
252
|
+
'countingType' => counting_type
|
|
253
|
+
))
|
|
254
|
+
id_of(http.post('/ads/google/conversions', body))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Offline conversions, matched to a click. Needs `publish` as well as `ads`.
|
|
258
|
+
def upload_conversions(workspace_id:, connection_id:, customer_id:, conversions:)
|
|
259
|
+
body = scope(workspace_id, connection_id, customer_id).merge('conversions' => conversions)
|
|
260
|
+
uploaded(http.post('/ads/google/conversions/upload', body))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Needs `publish` as well as `ads`.
|
|
264
|
+
def upload_conversion_adjustments(workspace_id:, connection_id:, customer_id:, adjustments:)
|
|
265
|
+
body = scope(workspace_id, connection_id, customer_id).merge('adjustments' => adjustments)
|
|
266
|
+
uploaded(http.post('/ads/google/conversions/adjustments', body))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# ── Recommendations ──
|
|
270
|
+
|
|
271
|
+
# Google's own read on what the account should change next. `types`
|
|
272
|
+
# narrows to those recommendation types.
|
|
273
|
+
def recommendations(connection_id:, customer_id:, types: nil, workspace_id: nil)
|
|
274
|
+
extra = types.nil? || types.empty? ? {} : { 'types' => Array(types).join(',') }
|
|
275
|
+
parse_list(GoogleRecommendation, unwrap(http.get(
|
|
276
|
+
'/ads/google/recommendations',
|
|
277
|
+
params(connection_id, customer_id, workspace_id, extra)
|
|
278
|
+
)))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The account's score and weight, and the score of each live campaign.
|
|
282
|
+
def optimization_score(connection_id:, customer_id:, workspace_id: nil)
|
|
283
|
+
GoogleOptimizationScore.new(unwrap(http.get(
|
|
284
|
+
'/ads/google/optimization-score',
|
|
285
|
+
params(connection_id, customer_id, workspace_id)
|
|
286
|
+
)))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Applies each one, which changes what the live account serves or bids.
|
|
290
|
+
# Needs `publish` as well as `ads`.
|
|
291
|
+
def apply_recommendations(workspace_id:, connection_id:, customer_id:, ids:)
|
|
292
|
+
body = scope(workspace_id, connection_id, customer_id).merge('ids' => Array(ids))
|
|
293
|
+
counted(http.post('/ads/google/recommendations/apply', body), 'applied')
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Hides each one so Google stops surfacing it. Needs `publish` as well as `ads`.
|
|
297
|
+
def dismiss_recommendations(workspace_id:, connection_id:, customer_id:, ids:)
|
|
298
|
+
body = scope(workspace_id, connection_id, customer_id).merge('ids' => Array(ids))
|
|
299
|
+
counted(http.post('/ads/google/recommendations/dismiss', body), 'dismissed')
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# ── GAQL ──
|
|
303
|
+
|
|
304
|
+
# A read-only GAQL SELECT; rows come back exactly as Google returns them.
|
|
305
|
+
def query(connection_id:, customer_id:, query:, workspace_id: nil)
|
|
306
|
+
body = compact_nil(
|
|
307
|
+
'workspaceId' => workspace_id,
|
|
308
|
+
'connectionId' => connection_id,
|
|
309
|
+
'customerId' => customer_id,
|
|
310
|
+
'query' => query
|
|
311
|
+
)
|
|
312
|
+
result = unwrap(http.post('/ads/insights/query', body))
|
|
313
|
+
rows = result.is_a?(Hash) ? result['rows'] : nil
|
|
314
|
+
rows.is_a?(Array) ? rows : []
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private
|
|
318
|
+
|
|
319
|
+
def scope(workspace_id, connection_id, customer_id)
|
|
320
|
+
{ 'workspaceId' => workspace_id, 'connectionId' => connection_id, 'customerId' => customer_id }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def params(connection_id, customer_id, workspace_id, extra = {})
|
|
324
|
+
compact_nil({
|
|
325
|
+
'workspace_id' => workspace_id,
|
|
326
|
+
'connection_id' => connection_id,
|
|
327
|
+
'customer_id' => customer_id
|
|
328
|
+
}.merge(extra))
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def id_of(body)
|
|
332
|
+
result = unwrap(body)
|
|
333
|
+
result.is_a?(Hash) && result['id'] ? result['id'].to_s : ''
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def uploaded(body)
|
|
337
|
+
counted(body, 'uploaded')
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def counted(body, key)
|
|
341
|
+
result = unwrap(body)
|
|
342
|
+
result.is_a?(Hash) ? result[key].to_i : 0
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fopost/resources/base'
|
|
4
|
+
|
|
5
|
+
module Fopost
|
|
6
|
+
module Resources
|
|
7
|
+
# `client.google_business` — manage a connected Google Business Profile location.
|
|
8
|
+
#
|
|
9
|
+
# Google grants Business Profile API access per project. Until that grant
|
|
10
|
+
# lands on a deployment every call here raises a 503 configuration_error.
|
|
11
|
+
#
|
|
12
|
+
# Responses relay Google's own shape, field for field, so they come back as
|
|
13
|
+
# plain hashes rather than models we would have to keep chasing.
|
|
14
|
+
class GoogleBusiness < Base
|
|
15
|
+
# The set fetched when a caller names no metrics.
|
|
16
|
+
DEFAULT_DAILY_METRICS = %w[
|
|
17
|
+
BUSINESS_IMPRESSIONS_DESKTOP_MAPS
|
|
18
|
+
BUSINESS_IMPRESSIONS_DESKTOP_SEARCH
|
|
19
|
+
BUSINESS_IMPRESSIONS_MOBILE_MAPS
|
|
20
|
+
BUSINESS_IMPRESSIONS_MOBILE_SEARCH
|
|
21
|
+
CALL_CLICKS
|
|
22
|
+
WEBSITE_CLICKS
|
|
23
|
+
BUSINESS_DIRECTION_REQUESTS
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
def get_location(account_id)
|
|
27
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/location")))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Patch the profile; an omitted keyword keeps a field, nil clears it.
|
|
31
|
+
def update_location(account_id, title: UNSET, description: UNSET, website_uri: UNSET,
|
|
32
|
+
primary_phone: UNSET, additional_phones: UNSET, store_code: UNSET,
|
|
33
|
+
regular_hours: UNSET)
|
|
34
|
+
body = compact_unset({
|
|
35
|
+
'title' => title,
|
|
36
|
+
'description' => description,
|
|
37
|
+
'website_uri' => website_uri,
|
|
38
|
+
'primary_phone' => primary_phone,
|
|
39
|
+
'additional_phones' => additional_phones,
|
|
40
|
+
'store_code' => store_code,
|
|
41
|
+
'regular_hours' => regular_hours
|
|
42
|
+
})
|
|
43
|
+
as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/location", json: body)))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The attribute values set on the location, or what Google offers it.
|
|
47
|
+
def get_attributes(account_id, available: nil, category_name: nil, region_code: nil, language_code: nil)
|
|
48
|
+
params = compact_nil({
|
|
49
|
+
'available' => available,
|
|
50
|
+
'category_name' => category_name,
|
|
51
|
+
'region_code' => region_code,
|
|
52
|
+
'language_code' => language_code
|
|
53
|
+
})
|
|
54
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/attributes", params)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only the named attributes change; every other one is left alone.
|
|
58
|
+
def update_attributes(account_id, attributes)
|
|
59
|
+
body = { 'attributes' => attributes }
|
|
60
|
+
as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/attributes", json: body)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_menus(account_id)
|
|
64
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/menus")))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Google has no per-section patch, so the whole menu set is replaced.
|
|
68
|
+
def replace_menus(account_id, menus)
|
|
69
|
+
as_hash(unwrap(http.put("/accounts/#{account_id}/gbp/menus", { 'menus' => menus })))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_services(account_id)
|
|
73
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/services")))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def replace_services(account_id, service_items)
|
|
77
|
+
as_hash(unwrap(http.put("/accounts/#{account_id}/gbp/services", { 'service_items' => service_items })))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list_media(account_id, page_size: nil, page_token: nil)
|
|
81
|
+
params = compact_nil({ 'page_size' => page_size, 'page_token' => page_token })
|
|
82
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/media", params)))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Add a photo from the media library; JPEG or PNG, same workspace.
|
|
86
|
+
def add_media(account_id, media_id:, category: 'ADDITIONAL', description: nil)
|
|
87
|
+
body = compact_nil({ 'media_id' => media_id, 'category' => category, 'description' => description })
|
|
88
|
+
as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/media", body)))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def delete_media(account_id, media_key)
|
|
92
|
+
as_hash(unwrap(http.delete("/accounts/#{account_id}/gbp/media/#{media_key}")))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def list_place_actions(account_id)
|
|
96
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/place-actions")))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_place_action(account_id, uri:, place_action_type:, is_preferred: nil)
|
|
100
|
+
body = compact_nil({ 'uri' => uri, 'place_action_type' => place_action_type,
|
|
101
|
+
'is_preferred' => is_preferred })
|
|
102
|
+
as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/place-actions", body)))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def update_place_action(account_id, link_id, uri: UNSET, is_preferred: UNSET)
|
|
106
|
+
body = compact_unset({ 'uri' => uri, 'is_preferred' => is_preferred })
|
|
107
|
+
as_hash(unwrap(http.request(:patch, "/accounts/#{account_id}/gbp/place-actions/#{link_id}", json: body)))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def delete_place_action(account_id, link_id)
|
|
111
|
+
as_hash(unwrap(http.delete("/accounts/#{account_id}/gbp/place-actions/#{link_id}")))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The ways Google will let this location be verified.
|
|
115
|
+
def get_verification_options(account_id, language_code: nil)
|
|
116
|
+
params = compact_nil({ 'language_code' => language_code })
|
|
117
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/verification", params)))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The response names the pending verification to complete with the PIN.
|
|
121
|
+
def start_verification(account_id, method:, language_code: nil, phone_number: nil,
|
|
122
|
+
email_address: nil, mailer_contact_name: nil)
|
|
123
|
+
body = compact_nil({
|
|
124
|
+
'method' => method,
|
|
125
|
+
'language_code' => language_code,
|
|
126
|
+
'phone_number' => phone_number,
|
|
127
|
+
'email_address' => email_address,
|
|
128
|
+
'mailer_contact_name' => mailer_contact_name
|
|
129
|
+
})
|
|
130
|
+
as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/verification/start", body)))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def complete_verification(account_id, verification_name:, pin:)
|
|
134
|
+
body = { 'verification_name' => verification_name, 'pin' => pin }
|
|
135
|
+
as_hash(unwrap(http.post("/accounts/#{account_id}/gbp/verification/complete", body)))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Daily impressions, calls, direction requests and clicks for the range.
|
|
139
|
+
def get_performance(account_id, start_date:, end_date:, daily_metrics: nil)
|
|
140
|
+
params = compact_nil({
|
|
141
|
+
'start_date' => start_date,
|
|
142
|
+
'end_date' => end_date,
|
|
143
|
+
'daily_metrics' => daily_metrics
|
|
144
|
+
})
|
|
145
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/performance", params)))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The search terms people used to find the listing, by month.
|
|
149
|
+
def get_search_keywords(account_id, start_date:, end_date:, page_token: nil)
|
|
150
|
+
params = compact_nil({
|
|
151
|
+
'keywords' => true,
|
|
152
|
+
'start_date' => start_date,
|
|
153
|
+
'end_date' => end_date,
|
|
154
|
+
'page_token' => page_token
|
|
155
|
+
})
|
|
156
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/gbp/performance", params)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Hand the location to another workspace; the caller must own both.
|
|
160
|
+
def assign(account_id, workspace_id:)
|
|
161
|
+
AccountMove.new(unwrap(http.post("/accounts/#{account_id}/gbp/assign",
|
|
162
|
+
{ 'workspace_id' => workspace_id })))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|