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,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Fopost
|
|
6
|
+
module Resources
|
|
7
|
+
# `client.analytics` — deeper posting analytics.
|
|
8
|
+
#
|
|
9
|
+
# Derived from the repeated readings the platform collector takes of every
|
|
10
|
+
# post as it ages. Every call needs the `analytics` scope.
|
|
11
|
+
class Analytics < Base
|
|
12
|
+
# How engagement accumulates with a post's age, and where the half-life
|
|
13
|
+
# falls. `days` selects posts by publish time, not reading time.
|
|
14
|
+
def decay(days: nil, workspace_id: nil, account_id: nil)
|
|
15
|
+
query = compact_nil('days' => days, 'workspace_id' => workspace_id, 'accountId' => account_id)
|
|
16
|
+
ContentDecay.new(unwrap(http.get('/analytics/decay', query)))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Weekly posting cadence set against what each cadence earned per post.
|
|
20
|
+
def frequency(days: nil, workspace_id: nil, account_id: nil)
|
|
21
|
+
query = compact_nil('days' => days, 'workspace_id' => workspace_id, 'accountId' => account_id)
|
|
22
|
+
PostingFrequency.new(unwrap(http.get('/analytics/frequency', query)))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every reading held for one post, oldest first, one timeline per
|
|
26
|
+
# delivery. `id_or_permalink` is a FoPost post id or the permalink of a
|
|
27
|
+
# post made natively on the network.
|
|
28
|
+
def timeline(id_or_permalink)
|
|
29
|
+
path = "/analytics/posts/#{CGI.escape(id_or_permalink.to_s)}/timeline"
|
|
30
|
+
PostTimeline.new(unwrap(http.get(path)))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Readings recorded after `since`, oldest first, with a cursor to
|
|
34
|
+
# continue. Poll this to mirror the metrics into your own store; omitting
|
|
35
|
+
# `since` gives the last seven days.
|
|
36
|
+
def changes(since: nil, limit: nil, workspace_id: nil, account_id: nil)
|
|
37
|
+
query = compact_nil(
|
|
38
|
+
'since' => iso8601(since),
|
|
39
|
+
'limit' => limit,
|
|
40
|
+
'workspace_id' => workspace_id,
|
|
41
|
+
'accountId' => account_id
|
|
42
|
+
)
|
|
43
|
+
MetricChangePage.new(unwrap(http.get('/analytics/changes', query)))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Re-read one post from the network now. Spends the same per-user budget
|
|
47
|
+
# as a full collection run, so a burst answers 429 with `retryAfter`.
|
|
48
|
+
def collect_post(id_or_permalink)
|
|
49
|
+
path = "/posts/#{CGI.escape(id_or_permalink.to_s)}/analytics/collect"
|
|
50
|
+
CollectPostResult.new(unwrap(http.post(path)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Posts on the account that never went out through FoPost, newest first.
|
|
54
|
+
def native_posts(account_id, page: 1, per_page: 20, days: nil)
|
|
55
|
+
query = compact_nil('page' => page, 'per_page' => per_page, 'days' => days)
|
|
56
|
+
body = http.get("/accounts/#{account_id}/native-posts", query)
|
|
57
|
+
items = parse_list(NativePost, body.is_a?(Hash) ? body['data'] : body)
|
|
58
|
+
raw_meta = body.is_a?(Hash) ? body['meta'] : nil
|
|
59
|
+
Page.new(items: items, meta: InboxPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {}))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.blog` — articles written for a website, as opposed to posts
|
|
6
|
+
# that go to a social network.
|
|
7
|
+
#
|
|
8
|
+
# Reads, settings and keyword edits need the `posts` scope. Anything that
|
|
9
|
+
# spends credits ({#suggest_seeds}, {#plan_keywords}, {#request_article},
|
|
10
|
+
# {#regenerate_article}) needs `ai` and answers 402 on an empty balance;
|
|
11
|
+
# {#publish_article} needs `publish` and answers 409 `no_target` when the
|
|
12
|
+
# settings name nowhere to publish.
|
|
13
|
+
class Blog < Base
|
|
14
|
+
# ── Settings ──────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
def get_settings(workspace_id)
|
|
17
|
+
BlogSettings.new(unwrap(http.get('/blog/settings', { 'workspace_id' => workspace_id })))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Partial update: only what you pass is sent.
|
|
21
|
+
#
|
|
22
|
+
# The answer carries `website_secret` only on the update that first sets
|
|
23
|
+
# `website_url`; keep it, it is never shown again. Pass `nil` to clear a
|
|
24
|
+
# nullable field.
|
|
25
|
+
def update_settings(workspace_id, site_url: UNSET, topic: UNSET, audience: UNSET, goal: UNSET,
|
|
26
|
+
focus_topics: UNSET, avoid_topics: UNSET, language: UNSET, location_code: UNSET,
|
|
27
|
+
cadence_per_week: UNSET, review_hold_days: UNSET, image_tier: UNSET,
|
|
28
|
+
in_body_images: UNSET, min_words: UNSET, max_words: UNSET, brand_voice_id: UNSET,
|
|
29
|
+
target_kind: UNSET, target_account_id: UNSET, website_url: UNSET)
|
|
30
|
+
body = compact_unset(
|
|
31
|
+
'siteUrl' => site_url,
|
|
32
|
+
'topic' => topic,
|
|
33
|
+
'audience' => audience,
|
|
34
|
+
'goal' => goal,
|
|
35
|
+
'focusTopics' => focus_topics,
|
|
36
|
+
'avoidTopics' => avoid_topics,
|
|
37
|
+
'language' => language,
|
|
38
|
+
'locationCode' => location_code,
|
|
39
|
+
'cadencePerWeek' => cadence_per_week,
|
|
40
|
+
'reviewHoldDays' => review_hold_days,
|
|
41
|
+
'imageTier' => image_tier,
|
|
42
|
+
'inBodyImages' => in_body_images,
|
|
43
|
+
'minWords' => min_words,
|
|
44
|
+
'maxWords' => max_words,
|
|
45
|
+
'brandVoiceId' => brand_voice_id,
|
|
46
|
+
'targetKind' => target_kind,
|
|
47
|
+
'targetAccountId' => target_account_id,
|
|
48
|
+
'websiteUrl' => website_url
|
|
49
|
+
)
|
|
50
|
+
response = as_hash(unwrap(http.patch('/blog/settings', { 'workspaceId' => workspace_id }.merge(body))))
|
|
51
|
+
BlogSettingsUpdate.new('settings' => response, 'website_secret' => response['website_secret'])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Issue a new website secret; the old one stops verifying at once.
|
|
55
|
+
# Returns the secret, shown this once.
|
|
56
|
+
def rotate_website_secret(workspace_id)
|
|
57
|
+
result = as_hash(unwrap(http.post('/blog/settings/website-secret', { 'workspaceId' => workspace_id })))
|
|
58
|
+
result['website_secret'].to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Send a signed `article.test` delivery to `website_url`. A site that
|
|
62
|
+
# does not answer 2xx comes back as a 502 `Fopost::Error` whose body
|
|
63
|
+
# carries the site's `http_status`.
|
|
64
|
+
def test_website(workspace_id)
|
|
65
|
+
BlogWebsiteTest.new(unwrap(http.post('/blog/settings/website-test', { 'workspaceId' => workspace_id })))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ── Keywords ──────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
# `status` takes one status or an array of them.
|
|
71
|
+
def list_keywords(workspace_id, status: nil, limit: nil)
|
|
72
|
+
params = { 'workspace_id' => workspace_id, 'status' => csv(status), 'limit' => limit }
|
|
73
|
+
parse_list(BlogKeyword, unwrap(http.get('/blog/keywords', params)))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# About 10 search terms from the blog goal and topics, to edit before
|
|
77
|
+
# {#plan_keywords}. Pass the current `seeds` to refine them and `prompt`
|
|
78
|
+
# (up to 500 characters) to steer. Needs `ai`.
|
|
79
|
+
def suggest_seeds(workspace_id, seeds: nil, prompt: nil)
|
|
80
|
+
body = compact_nil('workspaceId' => workspace_id, 'seeds' => seeds, 'prompt' => prompt)
|
|
81
|
+
as_hash(unwrap(http.post('/blog/keywords/suggest', body)))['seeds'] || []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Queue a research run; it answers before the run finishes. Results land
|
|
85
|
+
# as `suggested` keywords and never fill the calendar; set one to
|
|
86
|
+
# `planned` with {#update_keyword} to pick it. Follow the run in
|
|
87
|
+
# `plan_run` on {#get_settings}. Needs `ai`.
|
|
88
|
+
def plan_keywords(workspace_id, seeds: nil)
|
|
89
|
+
body = compact_nil('workspaceId' => workspace_id, 'seeds' => seeds)
|
|
90
|
+
as_hash(unwrap(http.post('/blog/keywords/plan', body)))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# `planned_for` is a date, YYYY-MM-DD.
|
|
94
|
+
def add_keyword(workspace_id, keyword:, planned_for: nil)
|
|
95
|
+
body = compact_nil('workspaceId' => workspace_id, 'keyword' => keyword, 'plannedFor' => date(planned_for))
|
|
96
|
+
BlogKeyword.new(unwrap(http.post('/blog/keywords', body)))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Partial update. `status: 'planned'` picks the keyword and gives it the
|
|
100
|
+
# next free date; `planned_for: nil` takes it off the calendar.
|
|
101
|
+
def update_keyword(workspace_id, keyword_id, status: UNSET, planned_for: UNSET)
|
|
102
|
+
body = compact_unset('status' => status, 'plannedFor' => UNSET.equal?(planned_for) ? UNSET : date(planned_for))
|
|
103
|
+
BlogKeyword.new(unwrap(patch("/blog/keywords/#{keyword_id}", workspace_id, body)))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def delete_keyword(workspace_id, keyword_id)
|
|
107
|
+
http.request(:delete, "/blog/keywords/#{keyword_id}", params: workspace(workspace_id))
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── Articles ──────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
# `status` takes one status or an array of them.
|
|
114
|
+
def list_articles(workspace_id, status: nil, limit: nil)
|
|
115
|
+
params = { 'workspace_id' => workspace_id, 'status' => csv(status), 'limit' => limit }
|
|
116
|
+
parse_list(BlogArticle, unwrap(http.get('/blog/articles', params)))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Queue an article. Give one of `title`, `keyword` or `keyword_id` (a
|
|
120
|
+
# keyword from the plan). The article comes back `queued`; poll
|
|
121
|
+
# {#get_article} or wait for the `article.published` webhook. Needs `ai`.
|
|
122
|
+
def request_article(workspace_id, title: nil, keyword: nil, keyword_id: nil, instructions: nil)
|
|
123
|
+
body = compact_nil(
|
|
124
|
+
'workspaceId' => workspace_id,
|
|
125
|
+
'title' => title,
|
|
126
|
+
'keyword' => keyword,
|
|
127
|
+
'keywordId' => keyword_id,
|
|
128
|
+
'instructions' => instructions
|
|
129
|
+
)
|
|
130
|
+
BlogArticle.new(unwrap(http.post('/blog/articles', body)))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get_article(workspace_id, article_id)
|
|
134
|
+
BlogArticleFull.new(unwrap(http.get("/blog/articles/#{article_id}", workspace(workspace_id))))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Partial update. An article still being written or already publishing
|
|
138
|
+
# answers 409. `publish_at` puts an article in review on the calendar (a
|
|
139
|
+
# draft answers 409 until submitted with {#submit_article});
|
|
140
|
+
# `publish_at: nil` returns it to draft.
|
|
141
|
+
def update_article(workspace_id, article_id, title: UNSET, slug: UNSET, excerpt: UNSET, meta_title: UNSET,
|
|
142
|
+
meta_description: UNSET, body_markdown: UNSET, tags: UNSET, publish_at: UNSET)
|
|
143
|
+
body = compact_unset(
|
|
144
|
+
'title' => title,
|
|
145
|
+
'slug' => slug,
|
|
146
|
+
'excerpt' => excerpt,
|
|
147
|
+
'metaTitle' => meta_title,
|
|
148
|
+
'metaDescription' => meta_description,
|
|
149
|
+
'bodyMarkdown' => body_markdown,
|
|
150
|
+
'tags' => tags,
|
|
151
|
+
'publishAt' => UNSET.equal?(publish_at) ? UNSET : iso8601(publish_at)
|
|
152
|
+
)
|
|
153
|
+
BlogArticleFull.new(unwrap(patch("/blog/articles/#{article_id}", workspace_id, body)))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Write it again from the same brief. Needs `ai`.
|
|
157
|
+
def regenerate_article(workspace_id, article_id)
|
|
158
|
+
BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/regenerate",
|
|
159
|
+
params: workspace(workspace_id))))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Publish an article in review to the configured target. Needs
|
|
163
|
+
# `publish`; a draft answers 409 until submitted with {#submit_article},
|
|
164
|
+
# and 409 `no_target` means the settings name nowhere to publish.
|
|
165
|
+
def publish_article(workspace_id, article_id)
|
|
166
|
+
BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/publish",
|
|
167
|
+
params: workspace(workspace_id))))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Move a draft, failed or rejected article to `in_review`; any other
|
|
171
|
+
# state answers 409.
|
|
172
|
+
def submit_article(workspace_id, article_id)
|
|
173
|
+
BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/submit",
|
|
174
|
+
params: workspace(workspace_id))))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def reject_article(workspace_id, article_id)
|
|
178
|
+
BlogArticle.new(unwrap(http.request(:post, "/blog/articles/#{article_id}/reject",
|
|
179
|
+
params: workspace(workspace_id))))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def delete_article(workspace_id, article_id)
|
|
183
|
+
http.request(:delete, "/blog/articles/#{article_id}", params: workspace(workspace_id))
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def workspace(workspace_id)
|
|
190
|
+
{ 'workspace_id' => workspace_id }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def patch(path, workspace_id, body)
|
|
194
|
+
http.request(:patch, path, json: body, params: workspace(workspace_id))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def csv(value)
|
|
198
|
+
value.is_a?(Array) ? value.join(',') : value
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def date(value)
|
|
202
|
+
value.respond_to?(:strftime) ? value.strftime('%Y-%m-%d') : value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.blogs` — content a connected site already owns.
|
|
6
|
+
#
|
|
7
|
+
# Most of this SDK creates content. These calls reach what is already
|
|
8
|
+
# there: the articles on a WordPress site or a Shopify store's blog, and a
|
|
9
|
+
# Shopify store's products. Every id here is the platform's own, never a
|
|
10
|
+
# FoPost id.
|
|
11
|
+
#
|
|
12
|
+
# Reads need the `posts` scope. Anything that changes the site needs
|
|
13
|
+
# `posts` and `publish`, because a change here is visible to the site's own
|
|
14
|
+
# readers.
|
|
15
|
+
class Blogs < Base
|
|
16
|
+
# Blogs the account can write to. Shopify reports every blog on the
|
|
17
|
+
# store; WordPress reports its one implicit blog, under the id `default`.
|
|
18
|
+
def list_blogs(account_id)
|
|
19
|
+
parse_list(RemoteBlog, unwrap(http.get("/accounts/#{account_id}/blogs")))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Articles on the blog, newest first, drafts included.
|
|
23
|
+
#
|
|
24
|
+
# `status` is published, draft, pending or scheduled; `q` matches the
|
|
25
|
+
# title; `limit` is 1 to 50.
|
|
26
|
+
def list_articles(account_id, blog_id, limit: nil, status: nil, q: nil)
|
|
27
|
+
params = compact_nil('limit' => limit, 'status' => status, 'q' => q)
|
|
28
|
+
parse_list(
|
|
29
|
+
RemoteArticle,
|
|
30
|
+
unwrap(http.get("/accounts/#{account_id}/blogs/#{blog_id}/articles", params))
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_article(account_id, blog_id, article_id)
|
|
35
|
+
RemoteArticle.new(
|
|
36
|
+
unwrap(http.get("/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}"))
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write a new article to the blog. Needs the `publish` scope.
|
|
41
|
+
#
|
|
42
|
+
# `body` is FoPost body markup; the site's own format is rendered from it.
|
|
43
|
+
def create_article(account_id, blog_id, title:, body:, excerpt: nil, status: nil,
|
|
44
|
+
tags: nil, author_name: nil, image_url: nil)
|
|
45
|
+
payload = compact_nil(
|
|
46
|
+
'title' => title,
|
|
47
|
+
'body' => body,
|
|
48
|
+
'excerpt' => excerpt,
|
|
49
|
+
'status' => status,
|
|
50
|
+
'tags' => tags&.to_a,
|
|
51
|
+
'author_name' => author_name,
|
|
52
|
+
'image_url' => image_url
|
|
53
|
+
)
|
|
54
|
+
RemoteArticle.new(
|
|
55
|
+
unwrap(http.post("/accounts/#{account_id}/blogs/#{blog_id}/articles", payload))
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Change the live article in place. Needs the `publish` scope.
|
|
60
|
+
#
|
|
61
|
+
# Only the keywords you pass are touched, and the article is addressed by
|
|
62
|
+
# its own id, so an edit never creates a second post on the site. Pass at
|
|
63
|
+
# least one field.
|
|
64
|
+
def update_article(account_id, blog_id, article_id, title: UNSET, body: UNSET,
|
|
65
|
+
excerpt: UNSET, status: UNSET, tags: UNSET, author_name: UNSET,
|
|
66
|
+
image_url: UNSET)
|
|
67
|
+
payload = compact_unset(
|
|
68
|
+
'title' => title,
|
|
69
|
+
'body' => body,
|
|
70
|
+
'excerpt' => excerpt,
|
|
71
|
+
'status' => status,
|
|
72
|
+
'tags' => UNSET.equal?(tags) ? UNSET : tags&.to_a,
|
|
73
|
+
'author_name' => author_name,
|
|
74
|
+
'image_url' => image_url
|
|
75
|
+
)
|
|
76
|
+
RemoteArticle.new(
|
|
77
|
+
unwrap(http.request(
|
|
78
|
+
:patch,
|
|
79
|
+
"/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}",
|
|
80
|
+
json: payload
|
|
81
|
+
))
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remove the article from the site. Needs `publish`; cannot be undone.
|
|
86
|
+
def delete_article(account_id, blog_id, article_id)
|
|
87
|
+
http.delete("/accounts/#{account_id}/blogs/#{blog_id}/articles/#{article_id}")
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The store's products. `status` is active, draft or archived.
|
|
92
|
+
def list_products(account_id, limit: nil, status: nil, q: nil)
|
|
93
|
+
params = compact_nil('limit' => limit, 'status' => status, 'q' => q)
|
|
94
|
+
parse_list(RemoteProduct, unwrap(http.get("/accounts/#{account_id}/products", params)))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Change a product on the store. Needs `publish`; only what you pass changes.
|
|
98
|
+
def update_product(account_id, product_id, title: UNSET, description: UNSET,
|
|
99
|
+
status: UNSET, tags: UNSET, product_type: UNSET, vendor: UNSET)
|
|
100
|
+
payload = compact_unset(
|
|
101
|
+
'title' => title,
|
|
102
|
+
'description' => description,
|
|
103
|
+
'status' => status,
|
|
104
|
+
'tags' => UNSET.equal?(tags) ? UNSET : tags&.to_a,
|
|
105
|
+
'product_type' => product_type,
|
|
106
|
+
'vendor' => vendor
|
|
107
|
+
)
|
|
108
|
+
RemoteProduct.new(
|
|
109
|
+
unwrap(http.request(:patch, "/accounts/#{account_id}/products/#{product_id}",
|
|
110
|
+
json: payload))
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.broadcasts` — one message into every conversation the workspace
|
|
6
|
+
# already has with a segment of its contacts.
|
|
7
|
+
#
|
|
8
|
+
# Nothing is sent into a closed messaging window. Messenger and Instagram
|
|
9
|
+
# take a business-initiated message only within 24 hours of the contact's
|
|
10
|
+
# last one, so recipients outside it come back skipped with
|
|
11
|
+
# `window_closed` rather than attempted — which is why the number sent is
|
|
12
|
+
# often lower than the audience. Telegram, Slack, Bluesky and Reddit have
|
|
13
|
+
# no window.
|
|
14
|
+
#
|
|
15
|
+
# Reading needs the `inbox` scope; {#send} and {#cancel} also need
|
|
16
|
+
# `publish`.
|
|
17
|
+
class Broadcasts < Base
|
|
18
|
+
# One page of broadcasts, newest first. The result is Enumerable over
|
|
19
|
+
# its items.
|
|
20
|
+
#
|
|
21
|
+
# Omit `workspace_id` to span every workspace the key can reach; each
|
|
22
|
+
# broadcast then carries `workspace_id`.
|
|
23
|
+
def list(workspace_id: nil, status: nil, page: 1, per_page: 25)
|
|
24
|
+
body = http.get(
|
|
25
|
+
'/broadcasts',
|
|
26
|
+
{ 'workspace_id' => workspace_id, 'status' => status, 'page' => page, 'per_page' => per_page }
|
|
27
|
+
)
|
|
28
|
+
paged(Broadcast, body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get(broadcast_id)
|
|
32
|
+
Broadcast.new(unwrap(http.get("/broadcasts/#{broadcast_id}")))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Create it without sending.
|
|
36
|
+
#
|
|
37
|
+
# Give `scheduled_at` to have it go out on its own at that time;
|
|
38
|
+
# otherwise call {#send}. An omitted `audience` means every contact in
|
|
39
|
+
# the workspace.
|
|
40
|
+
def create(workspace_id:, account_id:, name:, text:, media_id: nil, audience: nil, scheduled_at: nil)
|
|
41
|
+
body = compact_nil(
|
|
42
|
+
'workspace_id' => workspace_id,
|
|
43
|
+
'account_id' => account_id,
|
|
44
|
+
'name' => name,
|
|
45
|
+
'text' => text,
|
|
46
|
+
'media_id' => media_id,
|
|
47
|
+
'audience' => audience,
|
|
48
|
+
'scheduled_at' => scheduled_at
|
|
49
|
+
)
|
|
50
|
+
Broadcast.new(unwrap(http.post('/broadcasts', body)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Partial update: only what you pass is sent. Only a draft or scheduled
|
|
54
|
+
# broadcast can be edited.
|
|
55
|
+
def update(broadcast_id, name: UNSET, text: UNSET, media_id: UNSET, audience: UNSET, scheduled_at: UNSET)
|
|
56
|
+
body = compact_unset(
|
|
57
|
+
'name' => name,
|
|
58
|
+
'text' => text,
|
|
59
|
+
'media_id' => media_id,
|
|
60
|
+
'audience' => audience,
|
|
61
|
+
'scheduled_at' => scheduled_at
|
|
62
|
+
)
|
|
63
|
+
Broadcast.new(unwrap(http.request(:patch, "/broadcasts/#{broadcast_id}", json: body)))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Freeze the audience into a recipient list and start sending.
|
|
67
|
+
#
|
|
68
|
+
# The returned `recipients` count is how many contacts matched, not how
|
|
69
|
+
# many will be messaged — the messaging window decides that. Needs the
|
|
70
|
+
# `publish` scope as well as `inbox`.
|
|
71
|
+
def send(broadcast_id)
|
|
72
|
+
as_hash(unwrap(http.post("/broadcasts/#{broadcast_id}/send", {})))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Stop it where it stands. Anyone not yet written to stays unsent;
|
|
76
|
+
# messages already delivered are not recalled. Needs `publish`.
|
|
77
|
+
def cancel(broadcast_id)
|
|
78
|
+
as_hash(unwrap(http.post("/broadcasts/#{broadcast_id}/cancel", {})))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One row per contact, with what became of their message. A skipped row
|
|
82
|
+
# carries `skip_reason`.
|
|
83
|
+
def recipients(broadcast_id, status: nil, page: 1, per_page: 50)
|
|
84
|
+
body = http.get(
|
|
85
|
+
"/broadcasts/#{broadcast_id}/recipients",
|
|
86
|
+
{ 'status' => status, 'page' => page, 'per_page' => per_page }
|
|
87
|
+
)
|
|
88
|
+
paged(BroadcastRecipient, body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Removes the broadcast and its recipient records. Messages already sent
|
|
92
|
+
# stay in the conversations they went to.
|
|
93
|
+
def delete(broadcast_id)
|
|
94
|
+
http.delete("/broadcasts/#{broadcast_id}")
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Broadcast lists answer `{data, pagination}`, not `{data, meta}`.
|
|
101
|
+
def paged(model, body)
|
|
102
|
+
hash = as_hash(body)
|
|
103
|
+
raw_meta = hash['pagination']
|
|
104
|
+
Page.new(
|
|
105
|
+
items: parse_list(model, hash['data']),
|
|
106
|
+
meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# `client.sequences` — a series of messages, each a delay after the one
|
|
112
|
+
# before, walked per enrolled contact.
|
|
113
|
+
#
|
|
114
|
+
# The messaging window applies to every step. A step that comes due
|
|
115
|
+
# outside it is skipped rather than sent, and the enrollment carries on —
|
|
116
|
+
# so someone can complete a sequence having received only some of its
|
|
117
|
+
# messages.
|
|
118
|
+
class Sequences < Base
|
|
119
|
+
def list(workspace_id: nil, page: 1, per_page: 25)
|
|
120
|
+
body = http.get('/sequences', { 'workspace_id' => workspace_id, 'page' => page, 'per_page' => per_page })
|
|
121
|
+
paged(Sequence, body)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def get(sequence_id)
|
|
125
|
+
Sequence.new(unwrap(http.get("/sequences/#{sequence_id}")))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Creating a sequence enrolls nobody.
|
|
129
|
+
#
|
|
130
|
+
# Each step is `{ 'delay_hours' => n, 'text' => '...' }`, with
|
|
131
|
+
# `delay_hours` counted from the previous step.
|
|
132
|
+
def create(workspace_id:, account_id:, name:, steps:, status: nil)
|
|
133
|
+
body = compact_nil(
|
|
134
|
+
'workspace_id' => workspace_id,
|
|
135
|
+
'account_id' => account_id,
|
|
136
|
+
'name' => name,
|
|
137
|
+
'steps' => steps,
|
|
138
|
+
'status' => status
|
|
139
|
+
)
|
|
140
|
+
Sequence.new(unwrap(http.post('/sequences', body)))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Partial update. Pausing stops every enrollment from firing without
|
|
144
|
+
# ending any of them; resuming picks them up where they stood.
|
|
145
|
+
def update(sequence_id, name: UNSET, steps: UNSET, status: UNSET)
|
|
146
|
+
body = compact_unset('name' => name, 'steps' => steps, 'status' => status)
|
|
147
|
+
Sequence.new(unwrap(http.request(:patch, "/sequences/#{sequence_id}", json: body)))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Put contacts on the sequence, by id or by the same audience filter a
|
|
151
|
+
# broadcast takes.
|
|
152
|
+
#
|
|
153
|
+
# Re-enrolling someone restarts their walk from the first step rather
|
|
154
|
+
# than running two in parallel. Needs `publish` as well as `inbox`.
|
|
155
|
+
def enroll(sequence_id, contact_ids: nil, audience: nil)
|
|
156
|
+
body = compact_nil('contact_ids' => contact_ids, 'audience' => audience)
|
|
157
|
+
as_hash(unwrap(http.post("/sequences/#{sequence_id}/enroll", body)))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Nothing further fires for them. Needs `publish`.
|
|
161
|
+
def unenroll(sequence_id, contact_ids)
|
|
162
|
+
as_hash(unwrap(http.post("/sequences/#{sequence_id}/unenroll", { 'contact_ids' => contact_ids })))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Who is on it, what step they are at, and when the next one is due.
|
|
166
|
+
def enrollments(sequence_id, page: 1, per_page: 50)
|
|
167
|
+
body = http.get("/sequences/#{sequence_id}/enrollments", { 'page' => page, 'per_page' => per_page })
|
|
168
|
+
paged(Enrollment, body)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Removes the sequence and every enrollment on it.
|
|
172
|
+
def delete(sequence_id)
|
|
173
|
+
http.delete("/sequences/#{sequence_id}")
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def paged(model, body)
|
|
180
|
+
hash = as_hash(body)
|
|
181
|
+
raw_meta = hash['pagination']
|
|
182
|
+
Page.new(
|
|
183
|
+
items: parse_list(model, hash['data']),
|
|
184
|
+
meta: ContactPageMeta.new(raw_meta.is_a?(Hash) ? raw_meta : {})
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|