fopost 0.2.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 +169 -0
- data/README.md +593 -3
- data/lib/fopost/client.rb +29 -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 +1855 -28
- 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 +910 -7
- 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 +73 -7
- 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 +17 -1
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Fopost
|
|
4
4
|
module Resources
|
|
5
|
-
# `client.inbox` — comments, mentions and direct messages on connected
|
|
5
|
+
# `client.inbox` — comments, mentions, reviews and direct messages on connected
|
|
6
6
|
# accounts. Every method needs the `inbox` scope.
|
|
7
7
|
class Inbox < Base
|
|
8
8
|
# One page of items, newest first. The result is Enumerable over its items.
|
|
9
9
|
#
|
|
10
|
-
# `type` is comment, mention or dm; `state` is unread, read, resolved or
|
|
10
|
+
# `type` is comment, mention, review or dm; `state` is unread, read, resolved or
|
|
11
11
|
# snoozed; `sort` is newest, oldest or unanswered.
|
|
12
12
|
def list(workspace_id: nil, type: nil, state: nil, platform: nil, account_id: nil, post_id: nil,
|
|
13
13
|
post_external_id: nil, conversation_id: nil, direction: nil, q: nil, sort: nil,
|
|
@@ -34,7 +34,8 @@ module Fopost
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
# One row per post with comments; `kind: 'mentions'` for posts the
|
|
37
|
-
# account was tagged in
|
|
37
|
+
# account was tagged in, `kind: 'reviews'` for one row per review left
|
|
38
|
+
# on the business, each carrying its rating.
|
|
38
39
|
def threads(workspace_id: nil, kind: nil, platform: nil, account_id: nil, state: nil, q: nil,
|
|
39
40
|
sort: nil, page: 1, per_page: 25)
|
|
40
41
|
body = http.get(
|
|
@@ -113,9 +114,16 @@ module Fopost
|
|
|
113
114
|
InboxItem.new(unwrap(http.request(:patch, "/inbox/#{item_id}", json: body)))
|
|
114
115
|
end
|
|
115
116
|
|
|
116
|
-
#
|
|
117
|
-
def
|
|
118
|
-
|
|
117
|
+
# Edit a comment the account wrote, on the platform. Needs `publish`.
|
|
118
|
+
def edit_comment(item_id, text:)
|
|
119
|
+
InboxItem.new(unwrap(http.request(:patch, "/inbox/#{item_id}", json: { 'text' => text })))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Send the reply on the platform as the connected account. `text` may be
|
|
123
|
+
# left out when `media_ids` is given; `media_ids` and `quick_replies` need `publish`.
|
|
124
|
+
def reply(item_id, text: nil, media_ids: nil, quick_replies: nil)
|
|
125
|
+
body = compact_nil('text' => text, 'media_ids' => media_ids, 'quick_replies' => quick_replies)
|
|
126
|
+
InboxReplyResult.new(unwrap(http.post("/inbox/#{item_id}/reply", body)))
|
|
119
127
|
end
|
|
120
128
|
|
|
121
129
|
def hide(item_id)
|
|
@@ -126,12 +134,70 @@ module Fopost
|
|
|
126
134
|
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unhide")))
|
|
127
135
|
end
|
|
128
136
|
|
|
129
|
-
# Delete the comment on the platform.
|
|
137
|
+
# Delete the comment on the platform, or our own reply (that also needs `publish`).
|
|
130
138
|
def delete(item_id)
|
|
131
139
|
http.delete("/inbox/#{item_id}")
|
|
132
140
|
nil
|
|
133
141
|
end
|
|
134
142
|
|
|
143
|
+
# Like the item (an upvote on Reddit, a favourite on Mastodon). Needs `publish`.
|
|
144
|
+
def like(item_id)
|
|
145
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/like")))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def unlike(item_id)
|
|
149
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unlike")))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Vote up or down where the network ranks by votes (Reddit), where `can_vote`
|
|
153
|
+
# is true. `direction` is 'up', 'down', or 'none' to take an earlier vote
|
|
154
|
+
# back. An upvote is the same call a like makes, so `liked` moves with it.
|
|
155
|
+
# Needs `publish`.
|
|
156
|
+
def vote(item_id, direction)
|
|
157
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/vote", { 'direction' => direction })))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Pin our own comment. Needs `publish`.
|
|
161
|
+
def pin(item_id)
|
|
162
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/pin")))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def unpin(item_id)
|
|
166
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/unpin")))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# React to a DM with an emoji; `nil` removes ours. Needs `publish`.
|
|
170
|
+
def react(item_id, reaction:)
|
|
171
|
+
InboxItem.new(unwrap(http.post("/inbox/#{item_id}/react", { 'reaction' => reaction })))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Open a DM: by `handle` from `account_id`, or as a private reply to the
|
|
175
|
+
# inbox comment `comment_id`. Needs `publish`.
|
|
176
|
+
def start_conversation(text:, account_id: nil, handle: nil, comment_id: nil, media_ids: nil)
|
|
177
|
+
body = compact_nil(
|
|
178
|
+
'account_id' => account_id,
|
|
179
|
+
'handle' => handle,
|
|
180
|
+
'comment_id' => comment_id,
|
|
181
|
+
'text' => text,
|
|
182
|
+
'media_ids' => media_ids
|
|
183
|
+
)
|
|
184
|
+
InboxStartedConversation.new(unwrap(http.post('/inbox/conversations', body)))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Show or clear the typing indicator in a DM thread. Returns whether it is on. Needs `publish`.
|
|
188
|
+
def set_typing(conversation_id, account_id:, on: true)
|
|
189
|
+
body = { 'account_id' => account_id, 'on' => on }
|
|
190
|
+
as_hash(unwrap(http.post("/inbox/conversations/#{conversation_id}/typing", body)))['typing']
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Pass a Messenger thread to another Meta app, or take it back without an `app_id`.
|
|
194
|
+
def handover(conversation_id, account_id:, app_id: nil, metadata: nil)
|
|
195
|
+
body = { 'account_id' => account_id }
|
|
196
|
+
body['app_id'] = app_id unless app_id.nil?
|
|
197
|
+
body['metadata'] = metadata unless metadata.nil?
|
|
198
|
+
InboxHandover.new(unwrap(http.post("/inbox/conversations/#{conversation_id}/handover", body)))
|
|
199
|
+
end
|
|
200
|
+
|
|
135
201
|
# Replies an automation or the agent drafted that a person still has to send.
|
|
136
202
|
def approvals(workspace_id: nil)
|
|
137
203
|
parse_list(InboxApproval, unwrap(http.get('/inbox/approvals', { 'workspace_id' => workspace_id })))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.knowledge` — what the workspace has told FoPost about itself.
|
|
6
|
+
#
|
|
7
|
+
# A source is an FAQ, a note, a page on your own site, or a plain-text/CSV
|
|
8
|
+
# item from the media library. Retrieval over these is what grounds a
|
|
9
|
+
# drafted inbox reply in your own answers instead of an invented one.
|
|
10
|
+
# Needs the `inbox` scope.
|
|
11
|
+
class Knowledge < Base
|
|
12
|
+
def list(workspace_id: nil)
|
|
13
|
+
parse_list(
|
|
14
|
+
KnowledgeSource,
|
|
15
|
+
unwrap(http.get('/knowledge/sources', { 'workspace_id' => workspace_id }))
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adds a source and queues it for indexing, so it comes back `pending`.
|
|
20
|
+
#
|
|
21
|
+
# `kind` is `faq`, `text`, `url` or `file`. An `faq` or `text` source
|
|
22
|
+
# needs `content`, a `url` source needs `url`, and a `file` source needs
|
|
23
|
+
# `media_id` pointing at a plain-text or CSV item in the same workspace.
|
|
24
|
+
def create(kind:, title:, content: nil, url: nil, media_id: nil, brand_voice_id: nil,
|
|
25
|
+
workspace_id: nil)
|
|
26
|
+
body = compact_nil(
|
|
27
|
+
'kind' => kind,
|
|
28
|
+
'title' => title,
|
|
29
|
+
'content' => content,
|
|
30
|
+
'url' => url,
|
|
31
|
+
'media_id' => media_id,
|
|
32
|
+
'brand_voice_id' => brand_voice_id,
|
|
33
|
+
'workspace_id' => workspace_id
|
|
34
|
+
)
|
|
35
|
+
KnowledgeSource.new(unwrap(http.post('/knowledge/sources', body)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Partial update. Changing the content or the URL returns the source to
|
|
39
|
+
# `pending` and re-indexes it.
|
|
40
|
+
def update(source_id, title: UNSET, content: UNSET, url: UNSET, brand_voice_id: UNSET)
|
|
41
|
+
body = compact_unset(
|
|
42
|
+
'title' => title,
|
|
43
|
+
'content' => content,
|
|
44
|
+
'url' => url,
|
|
45
|
+
'brand_voice_id' => brand_voice_id
|
|
46
|
+
)
|
|
47
|
+
KnowledgeSource.new(unwrap(http.patch("/knowledge/sources/#{source_id}", body)))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Removes the source and every passage indexed from it.
|
|
51
|
+
def delete(source_id)
|
|
52
|
+
http.delete("/knowledge/sources/#{source_id}")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reads the source again — a `url` source is re-fetched. Returns once queued.
|
|
57
|
+
def sync(source_id)
|
|
58
|
+
http.post("/knowledge/sources/#{source_id}/sync", {})
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The passages closest to a question, best first. Empty is the honest
|
|
63
|
+
# answer when nothing stored answers it.
|
|
64
|
+
def search(q, top_k: nil, brand_voice_id: nil, workspace_id: nil)
|
|
65
|
+
parse_list(KnowledgeMatch, unwrap(http.get('/knowledge/search', {
|
|
66
|
+
'q' => q,
|
|
67
|
+
'top_k' => top_k,
|
|
68
|
+
'brand_voice_id' => brand_voice_id,
|
|
69
|
+
'workspace_id' => workspace_id
|
|
70
|
+
})))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.media` — direct uploads to the media library.
|
|
6
|
+
#
|
|
7
|
+
# A direct upload is three steps: {#presign} reserves a slot, the bytes go
|
|
8
|
+
# straight to `upload_url` with the returned headers, and {#complete}
|
|
9
|
+
# turns the slot into a library item. {#upload_direct} does all three.
|
|
10
|
+
class Media < Base
|
|
11
|
+
# What a multipart request carries per file when `/media/limits` cannot be read.
|
|
12
|
+
DEFAULT_MULTIPART_MAX_FILE_BYTES = 100 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
# The upload ceilings for the account, by connected accounts or add-on.
|
|
15
|
+
def limits(workspace_id: nil)
|
|
16
|
+
UploadLimits.new(unwrap(http.get('/media/limits', { 'workspaceId' => workspace_id })))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Reserve an upload slot. `size` is the exact byte count of the file, up
|
|
20
|
+
# to the account's `max_file_bytes`.
|
|
21
|
+
def presign(workspace_id:, filename:, mime_type:, size:, folder_id: nil, origin: nil, brand_role: nil,
|
|
22
|
+
brand_voice_id: nil, person_consent: nil)
|
|
23
|
+
body = {
|
|
24
|
+
'workspaceId' => workspace_id,
|
|
25
|
+
'filename' => filename,
|
|
26
|
+
'mimeType' => mime_type,
|
|
27
|
+
'size' => size
|
|
28
|
+
}
|
|
29
|
+
body.merge!(
|
|
30
|
+
compact_nil(
|
|
31
|
+
'folderId' => folder_id,
|
|
32
|
+
'origin' => origin,
|
|
33
|
+
'brandRole' => brand_role,
|
|
34
|
+
'brandVoiceId' => brand_voice_id,
|
|
35
|
+
'personConsent' => person_consent
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
PresignedUpload.new(unwrap(http.post('/media/presign', body)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Turn an uploaded slot into a media item.
|
|
42
|
+
def complete(upload_id)
|
|
43
|
+
MediaItem.new(unwrap(http.post("/media/presign/#{upload_id}/complete")))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Presign, PUT `data` (a binary String), and complete in one call.
|
|
47
|
+
def upload_direct(workspace_id:, filename:, mime_type:, data:, **presign_options)
|
|
48
|
+
upload = presign(workspace_id: workspace_id, filename: filename, mime_type: mime_type,
|
|
49
|
+
size: data.bytesize, **presign_options)
|
|
50
|
+
headers = upload.headers.merge('Content-Length' => data.bytesize.to_s)
|
|
51
|
+
http.put_raw(upload.upload_url, data, headers)
|
|
52
|
+
complete(upload.upload_id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Fopost
|
|
4
6
|
module Resources
|
|
5
7
|
# `client.posts` — create, schedule, publish, and inspect posts.
|
|
@@ -66,9 +68,14 @@ module Fopost
|
|
|
66
68
|
# Create a draft or a scheduled post.
|
|
67
69
|
#
|
|
68
70
|
# `status` is "draft" or "scheduled"; a scheduled post needs `schedule_at`.
|
|
69
|
-
# To send a post out now, create it and call {#publish}.
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
# To send a post out now, create it and call {#publish}. `account_group_id`
|
|
72
|
+
# adds that group's accounts to `accounts`, so `accounts` may be left empty.
|
|
73
|
+
#
|
|
74
|
+
# `files` (paths, IOs, or `{ filename:, io:/data:, mime_type: }` hashes) are
|
|
75
|
+
# stored in the library and attached to the first block. Files over the
|
|
76
|
+
# multipart ceiling go through direct upload; the result is the same.
|
|
77
|
+
def create(workspace_id:, content:, accounts: [], account_group_id: nil, status: 'draft', schedule_at: nil,
|
|
78
|
+
labels: nil, title: nil, summary: nil, content_type: nil, settings: nil, files: nil, **extra)
|
|
72
79
|
body = {
|
|
73
80
|
'workspace_id' => workspace_id,
|
|
74
81
|
'status' => status,
|
|
@@ -77,6 +84,7 @@ module Fopost
|
|
|
77
84
|
}
|
|
78
85
|
body.merge!(
|
|
79
86
|
compact_nil(
|
|
87
|
+
'account_group_id' => account_group_id,
|
|
80
88
|
'schedule_at' => iso8601(schedule_at),
|
|
81
89
|
'labels' => labels&.to_a,
|
|
82
90
|
'title' => title,
|
|
@@ -87,6 +95,15 @@ module Fopost
|
|
|
87
95
|
)
|
|
88
96
|
body.merge!(stringify(extra))
|
|
89
97
|
|
|
98
|
+
parts = file_parts(files)
|
|
99
|
+
return Post.new(unwrap(http.post('/posts', body))) if parts.empty?
|
|
100
|
+
|
|
101
|
+
if multipart_fits?(parts, workspace_id)
|
|
102
|
+
fields = { 'post' => JSON.generate(body) }
|
|
103
|
+
return Post.new(unwrap(http.post_multipart('/posts', fields: fields, parts: multipart_parts(parts))))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
attach_media(body['content'], upload_parts(workspace_id, parts))
|
|
90
107
|
Post.new(unwrap(http.post('/posts', body)))
|
|
91
108
|
end
|
|
92
109
|
|
|
@@ -94,7 +111,7 @@ module Fopost
|
|
|
94
111
|
# to clear a field.
|
|
95
112
|
def update(post_id, content: UNSET, accounts: UNSET, status: UNSET, schedule_at: UNSET,
|
|
96
113
|
labels: UNSET, title: UNSET, summary: UNSET, content_type: UNSET,
|
|
97
|
-
settings: UNSET, **extra)
|
|
114
|
+
settings: UNSET, files: nil, **extra)
|
|
98
115
|
body = compact_unset(
|
|
99
116
|
'content' => UNSET.equal?(content) ? UNSET : normalize_content(content),
|
|
100
117
|
'accounts' => UNSET.equal?(accounts) ? UNSET : normalize_accounts(accounts),
|
|
@@ -108,6 +125,19 @@ module Fopost
|
|
|
108
125
|
)
|
|
109
126
|
body.merge!(stringify(extra))
|
|
110
127
|
|
|
128
|
+
parts = file_parts(files)
|
|
129
|
+
return Post.new(unwrap(http.put("/posts/#{post_id}", body))) if parts.empty?
|
|
130
|
+
|
|
131
|
+
if multipart_fits?(parts, nil)
|
|
132
|
+
fields = { 'post' => JSON.generate(body) }
|
|
133
|
+
multipart = http.put_multipart("/posts/#{post_id}", fields: fields, parts: multipart_parts(parts))
|
|
134
|
+
return Post.new(unwrap(multipart))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The direct path needs the workspace, and the current blocks when none were given.
|
|
138
|
+
existing = get(post_id)
|
|
139
|
+
body['content'] = normalize_content(existing.content) unless body.key?('content')
|
|
140
|
+
attach_media(body['content'], upload_parts(existing.workspace_id, parts))
|
|
111
141
|
Post.new(unwrap(http.put("/posts/#{post_id}", body)))
|
|
112
142
|
end
|
|
113
143
|
|
|
@@ -180,6 +210,47 @@ module Fopost
|
|
|
180
210
|
def stringify(hash)
|
|
181
211
|
hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
182
212
|
end
|
|
213
|
+
|
|
214
|
+
def file_parts(files)
|
|
215
|
+
Array(files).map { |file| FilePart.from(file) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# One multipart request when every file fits its per-file ceiling.
|
|
219
|
+
def multipart_fits?(parts, workspace_id)
|
|
220
|
+
ceiling = begin
|
|
221
|
+
media.limits(workspace_id: workspace_id).multipart_max_file_bytes
|
|
222
|
+
rescue Error
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
ceiling = Media::DEFAULT_MULTIPART_MAX_FILE_BYTES unless ceiling.is_a?(Integer)
|
|
226
|
+
parts.all? { |part| part.size <= ceiling }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def multipart_parts(parts)
|
|
230
|
+
parts.map do |part|
|
|
231
|
+
HTTP::Multipart::Part.new(name: 'files', filename: part.filename, mime_type: part.mime_type,
|
|
232
|
+
data: part.data)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def upload_parts(workspace_id, parts)
|
|
237
|
+
parts.map do |part|
|
|
238
|
+
item = media.upload_direct(workspace_id: workspace_id, filename: part.filename,
|
|
239
|
+
mime_type: part.mime_type, data: part.data)
|
|
240
|
+
compact_nil('type' => part.media_type, 'name' => item.name || part.filename, 'url' => item.url,
|
|
241
|
+
'size' => item.size || part.size)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def attach_media(blocks, items)
|
|
246
|
+
blocks << {} if blocks.empty?
|
|
247
|
+
first = blocks.first
|
|
248
|
+
first['media'] = Array(first['media']) + items
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def media
|
|
252
|
+
@media ||= Media.new(http)
|
|
253
|
+
end
|
|
183
254
|
end
|
|
184
255
|
end
|
|
185
256
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.validate` — check a draft, its length, or a media URL against
|
|
6
|
+
# platform rules before creating a post. Nothing is stored server-side.
|
|
7
|
+
class Validate < Base
|
|
8
|
+
# Full preflight: blockers per platform plus advisory signals.
|
|
9
|
+
def post(platforms:, content: nil, media: nil)
|
|
10
|
+
body = compact_nil(
|
|
11
|
+
'content' => content,
|
|
12
|
+
'media' => media&.map { |item| item.transform_keys(&:to_s) },
|
|
13
|
+
'platforms' => platforms.to_a
|
|
14
|
+
)
|
|
15
|
+
ValidatePostResult.new(unwrap(http.post('/validate/post', body)))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Text length against each platform's limit.
|
|
19
|
+
def length(text:, platforms:)
|
|
20
|
+
body = { 'text' => text, 'platforms' => platforms.to_a }
|
|
21
|
+
ValidateLengthResult.new(unwrap(http.post('/validate/length', body)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Fetch a public URL and check the file. Answers 200 even when a check fails.
|
|
25
|
+
def media(url:)
|
|
26
|
+
ValidateMediaResult.new(unwrap(http.post('/validate/media', { 'url' => url })))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether a subreddit exists and takes a post from this Reddit account. The
|
|
30
|
+
# check runs with the account's own token, so `account_id` is required.
|
|
31
|
+
def subreddit(account_id:, name:)
|
|
32
|
+
params = { 'account_id' => account_id, 'name' => name }
|
|
33
|
+
SubredditCheck.new(unwrap(http.get('/validate/subreddit', params)))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.whatsapp` — a WhatsApp Business connection.
|
|
6
|
+
#
|
|
7
|
+
# The platform owns templates, flows, the profile and the commerce settings,
|
|
8
|
+
# so every method here is a live read or write against the customer's own
|
|
9
|
+
# WhatsApp Business Account. All of it answers 503 until WhatsApp is set up.
|
|
10
|
+
class Whatsapp < Base
|
|
11
|
+
# ─── Profile ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def profile(account_id)
|
|
14
|
+
WhatsappProfile.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/profile")))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update_profile(account_id, about: UNSET, address: UNSET, description: UNSET,
|
|
18
|
+
vertical: UNSET, websites: UNSET, profile_picture_media_id: UNSET)
|
|
19
|
+
body = compact_unset(
|
|
20
|
+
'about' => about,
|
|
21
|
+
'address' => address,
|
|
22
|
+
'description' => description,
|
|
23
|
+
'vertical' => vertical,
|
|
24
|
+
'websites' => websites,
|
|
25
|
+
'profile_picture_media_id' => profile_picture_media_id
|
|
26
|
+
)
|
|
27
|
+
WhatsappProfile.new(unwrap(http.request(:patch, "/accounts/#{account_id}/whatsapp/profile", json: body)))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A review, not a write: the number keeps its old name until it passes.
|
|
31
|
+
def request_display_name(account_id, display_name)
|
|
32
|
+
body = { 'display_name' => display_name }
|
|
33
|
+
as_hash(unwrap(http.post("/accounts/#{account_id}/whatsapp/profile/display-name", body)))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def set_username(account_id, username)
|
|
37
|
+
body = { 'username' => username }
|
|
38
|
+
WhatsappProfile.new(unwrap(http.put("/accounts/#{account_id}/whatsapp/profile/username", body)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ─── Templates ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def templates(account_id, after: nil)
|
|
44
|
+
params = compact_nil('after' => after)
|
|
45
|
+
parse_list(WhatsappTemplate, unwrap(http.get("/accounts/#{account_id}/whatsapp/templates", params)))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The platform's pre-written templates, for adapting instead of drafting.
|
|
49
|
+
def template_library(account_id, search: nil)
|
|
50
|
+
params = compact_nil('search' => search)
|
|
51
|
+
body = unwrap(http.get("/accounts/#{account_id}/whatsapp/templates/library", params))
|
|
52
|
+
body.is_a?(Array) ? body : []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def template(account_id, template_id)
|
|
56
|
+
WhatsappTemplate.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/templates/#{template_id}")))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Files a template for review; the result carries the status it was given.
|
|
60
|
+
def create_template(account_id, name:, language:, category:, components:,
|
|
61
|
+
allow_category_change: nil)
|
|
62
|
+
body = compact_nil(
|
|
63
|
+
'name' => name,
|
|
64
|
+
'language' => language,
|
|
65
|
+
'category' => category,
|
|
66
|
+
'components' => components.map { |c| c.transform_keys(&:to_s) },
|
|
67
|
+
'allow_category_change' => allow_category_change
|
|
68
|
+
)
|
|
69
|
+
WhatsappTemplate.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/templates", body)))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def import_template(account_id, library_template_name:, name:, language:, category:,
|
|
73
|
+
library_template_button_inputs: nil)
|
|
74
|
+
body = compact_nil(
|
|
75
|
+
'library_template_name' => library_template_name,
|
|
76
|
+
'name' => name,
|
|
77
|
+
'language' => language,
|
|
78
|
+
'category' => category,
|
|
79
|
+
'library_template_button_inputs' => library_template_button_inputs
|
|
80
|
+
)
|
|
81
|
+
WhatsappTemplate.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/templates/import", body)))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def update_template(account_id, template_id, category: nil, components: nil)
|
|
85
|
+
body = compact_nil('category' => category, 'components' => components)
|
|
86
|
+
path = "/accounts/#{account_id}/whatsapp/templates/#{template_id}"
|
|
87
|
+
WhatsappTemplate.new(unwrap(http.request(:patch, path, json: body)))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The name is required: it is what the platform deletes by.
|
|
91
|
+
def delete_template(account_id, template_id, name:)
|
|
92
|
+
query = URI.encode_www_form('name' => name)
|
|
93
|
+
as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/templates/#{template_id}?#{query}")))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ─── Groups ──────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
def groups(account_id)
|
|
99
|
+
parse_list(WhatsappGroup, unwrap(http.get("/accounts/#{account_id}/whatsapp/groups")))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Participation is invite-only: send the invite link, there is no add.
|
|
103
|
+
def create_group(account_id, subject:, description: nil)
|
|
104
|
+
body = compact_nil('subject' => subject, 'description' => description)
|
|
105
|
+
WhatsappGroup.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/groups", body)))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def group(account_id, group_id)
|
|
109
|
+
WhatsappGroup.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/groups/#{group_id}")))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def update_group(account_id, group_id, subject: nil, description: nil)
|
|
113
|
+
body = compact_nil('subject' => subject, 'description' => description)
|
|
114
|
+
path = "/accounts/#{account_id}/whatsapp/groups/#{group_id}"
|
|
115
|
+
WhatsappGroup.new(unwrap(http.request(:patch, path, json: body)))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete_group(account_id, group_id)
|
|
119
|
+
as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/groups/#{group_id}")))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def group_invite_link(account_id, group_id)
|
|
123
|
+
body = as_hash(unwrap(http.get("/accounts/#{account_id}/whatsapp/groups/#{group_id}/invite-link")))
|
|
124
|
+
body['inviteLink']
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Issues a new link and invalidates the old one.
|
|
128
|
+
def reset_group_invite_link(account_id, group_id)
|
|
129
|
+
body = as_hash(unwrap(http.post("/accounts/#{account_id}/whatsapp/groups/#{group_id}/invite-link")))
|
|
130
|
+
body['inviteLink']
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def remove_group_participants(account_id, group_id, users)
|
|
134
|
+
path = "/accounts/#{account_id}/whatsapp/groups/#{group_id}/participants"
|
|
135
|
+
as_hash(unwrap(http.delete(path, { 'users' => users.to_a })))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ─── Blocking ────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def blocked(account_id, after: nil)
|
|
141
|
+
params = compact_nil('after' => after)
|
|
142
|
+
body = unwrap(http.get("/accounts/#{account_id}/whatsapp/block", params))
|
|
143
|
+
body.is_a?(Array) ? body : []
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def block_users(account_id, users)
|
|
147
|
+
body = { 'users' => users.to_a }
|
|
148
|
+
WhatsappBlockResult.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/block", body)))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def unblock_users(account_id, users)
|
|
152
|
+
body = { 'users' => users.to_a }
|
|
153
|
+
WhatsappBlockResult.new(unwrap(http.delete("/accounts/#{account_id}/whatsapp/block", body)))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ─── Commerce ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def commerce_settings(account_id)
|
|
159
|
+
WhatsappCommerceSettings.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/commerce")))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def update_commerce_settings(account_id, cart_enabled: nil, catalog_visible: nil)
|
|
163
|
+
body = compact_nil('is_cart_enabled' => cart_enabled, 'is_catalog_visible' => catalog_visible)
|
|
164
|
+
path = "/accounts/#{account_id}/whatsapp/commerce"
|
|
165
|
+
WhatsappCommerceSettings.new(unwrap(http.request(:patch, path, json: body)))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def link_catalog(account_id, catalog_id)
|
|
169
|
+
body = { 'catalog_id' => catalog_id }
|
|
170
|
+
path = "/accounts/#{account_id}/whatsapp/commerce/catalog"
|
|
171
|
+
WhatsappCommerceSettings.new(unwrap(http.post(path, body)))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ─── Flows ───────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def flows(account_id)
|
|
177
|
+
parse_list(WhatsappFlow, unwrap(http.get("/accounts/#{account_id}/whatsapp/flows")))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def flow(account_id, flow_id)
|
|
181
|
+
WhatsappFlow.new(unwrap(http.get("/accounts/#{account_id}/whatsapp/flows/#{flow_id}")))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def create_flow(account_id, name:, categories:, endpoint_uri: nil, clone_flow_id: nil)
|
|
185
|
+
body = compact_nil(
|
|
186
|
+
'name' => name,
|
|
187
|
+
'categories' => categories.to_a,
|
|
188
|
+
'endpoint_uri' => endpoint_uri,
|
|
189
|
+
'clone_flow_id' => clone_flow_id
|
|
190
|
+
)
|
|
191
|
+
WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows", body)))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def update_flow(account_id, flow_id, name: nil, categories: nil, endpoint_uri: nil)
|
|
195
|
+
body = compact_nil('name' => name, 'categories' => categories, 'endpoint_uri' => endpoint_uri)
|
|
196
|
+
path = "/accounts/#{account_id}/whatsapp/flows/#{flow_id}"
|
|
197
|
+
WhatsappFlow.new(unwrap(http.request(:patch, path, json: body)))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Drafts only; a published flow is deprecated instead.
|
|
201
|
+
def delete_flow(account_id, flow_id)
|
|
202
|
+
as_hash(unwrap(http.delete("/accounts/#{account_id}/whatsapp/flows/#{flow_id}")))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The platform answers with its validation errors rather than refusing.
|
|
206
|
+
def upload_flow_json(account_id, flow_id, flow_json)
|
|
207
|
+
body = { 'flow_json' => flow_json }
|
|
208
|
+
path = "/accounts/#{account_id}/whatsapp/flows/#{flow_id}/json"
|
|
209
|
+
WhatsappFlowJsonResult.new(unwrap(http.put(path, body)))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def publish_flow(account_id, flow_id)
|
|
213
|
+
WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows/#{flow_id}/publish")))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def deprecate_flow(account_id, flow_id)
|
|
217
|
+
WhatsappFlow.new(unwrap(http.post("/accounts/#{account_id}/whatsapp/flows/#{flow_id}/deprecate")))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def flow_responses(account_id)
|
|
221
|
+
path = "/accounts/#{account_id}/whatsapp/flows/responses"
|
|
222
|
+
parse_list(WhatsappFlowResponse, unwrap(http.get(path)))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def encryption_key_status(account_id)
|
|
226
|
+
path = "/accounts/#{account_id}/whatsapp/flows/encryption-key"
|
|
227
|
+
WhatsappEncryptionKeyStatus.new(unwrap(http.get(path)))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The public half only; the private half stays with the customer.
|
|
231
|
+
def set_encryption_key(account_id, business_public_key)
|
|
232
|
+
body = { 'business_public_key' => business_public_key }
|
|
233
|
+
path = "/accounts/#{account_id}/whatsapp/flows/encryption-key"
|
|
234
|
+
WhatsappEncryptionKeyStatus.new(unwrap(http.put(path, body)))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ─── Account state and sandbox ───────────────────────────────
|
|
238
|
+
|
|
239
|
+
def account_events(account_id)
|
|
240
|
+
as_hash(unwrap(http.get("/accounts/#{account_id}/whatsapp/events")))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def sandbox_sessions(workspace_id:)
|
|
244
|
+
params = { 'workspaceId' => workspace_id }
|
|
245
|
+
parse_list(WhatsappSandboxSession, unwrap(http.get('/whatsapp/sandbox/sessions', params)))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Sends a template from the platform-owned test number; needs the publish scope.
|
|
249
|
+
def create_sandbox_session(workspace_id:, phone_number:)
|
|
250
|
+
body = { 'workspaceId' => workspace_id, 'phoneNumber' => phone_number }
|
|
251
|
+
WhatsappSandboxSession.new(unwrap(http.post('/whatsapp/sandbox/sessions', body)))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/fopost/version.rb
CHANGED