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
data/lib/fopost/platforms.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Fopost
|
|
|
15
15
|
slack
|
|
16
16
|
reddit
|
|
17
17
|
pinterest
|
|
18
|
+
snapchat
|
|
18
19
|
tumblr
|
|
19
20
|
dribbble
|
|
20
21
|
mewe
|
|
@@ -35,6 +36,7 @@ module Fopost
|
|
|
35
36
|
nostr
|
|
36
37
|
whop
|
|
37
38
|
skool
|
|
39
|
+
whatsapp
|
|
38
40
|
].freeze
|
|
39
41
|
|
|
40
42
|
POST_STATUSES = %w[
|
|
@@ -46,4 +48,29 @@ module Fopost
|
|
|
46
48
|
failed
|
|
47
49
|
cancelled
|
|
48
50
|
].freeze
|
|
51
|
+
|
|
52
|
+
BLOG_ARTICLE_STATUSES = %w[
|
|
53
|
+
queued
|
|
54
|
+
writing
|
|
55
|
+
draft
|
|
56
|
+
in_review
|
|
57
|
+
scheduled
|
|
58
|
+
publishing
|
|
59
|
+
published
|
|
60
|
+
failed
|
|
61
|
+
rejected
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
BLOG_KEYWORD_STATUSES = %w[
|
|
65
|
+
suggested
|
|
66
|
+
planned
|
|
67
|
+
written
|
|
68
|
+
dismissed
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
BLOG_TARGET_KINDS = %w[
|
|
72
|
+
product_blog
|
|
73
|
+
account
|
|
74
|
+
website
|
|
75
|
+
].freeze
|
|
49
76
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.account_groups` — named sets of accounts to post to together.
|
|
6
|
+
class AccountGroups < Base
|
|
7
|
+
def list(workspace_id: nil)
|
|
8
|
+
parse_list(AccountGroup, unwrap(http.get('/account-groups', { 'workspace_id' => workspace_id })))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create(workspace_id:, name:, account_ids: nil)
|
|
12
|
+
body = compact_nil('workspace_id' => workspace_id, 'name' => name, 'account_ids' => account_ids&.to_a)
|
|
13
|
+
AccountGroup.new(unwrap(http.post('/account-groups', body)))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(group_id)
|
|
17
|
+
AccountGroup.new(unwrap(http.get("/account-groups/#{group_id}")))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Rename the group.
|
|
21
|
+
def update(group_id, name:)
|
|
22
|
+
AccountGroup.new(unwrap(http.request(:patch, "/account-groups/#{group_id}", json: { 'name' => name })))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def delete(group_id)
|
|
26
|
+
http.delete("/account-groups/#{group_id}")
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Replace the group's members with exactly these accounts.
|
|
31
|
+
def set_members(group_id, account_ids)
|
|
32
|
+
body = { 'account_ids' => account_ids.to_a }
|
|
33
|
+
AccountGroup.new(unwrap(http.put("/account-groups/#{group_id}/members", body)))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
module Fopost
|
|
4
6
|
module Resources
|
|
5
7
|
# `client.accounts` — the social accounts connected to a workspace.
|
|
6
8
|
class Accounts < Base
|
|
7
9
|
# Connected accounts, across every workspace unless one is named.
|
|
8
|
-
def list(workspace_id: nil)
|
|
9
|
-
# This endpoint reads a camelCase
|
|
10
|
-
|
|
10
|
+
def list(workspace_id: nil, group_id: nil)
|
|
11
|
+
# This endpoint reads a camelCase workspaceId; posts and labels use snake.
|
|
12
|
+
params = { 'workspaceId' => workspace_id, 'group_id' => group_id }
|
|
13
|
+
parse_list(SocialAccount, unwrap(http.get('/accounts', params)))
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def get(account_id)
|
|
@@ -18,6 +21,445 @@ module Fopost
|
|
|
18
21
|
def health(account_id)
|
|
19
22
|
as_hash(unwrap(http.get("/accounts/#{account_id}/health")))
|
|
20
23
|
end
|
|
24
|
+
|
|
25
|
+
# The numbers only this account's network reports, in its own vocabulary:
|
|
26
|
+
# ad-break earnings, story taps, a retention curve, the search terms behind
|
|
27
|
+
# a listing. Keyed by the platform's own metric names, read from the newest
|
|
28
|
+
# collected snapshot rather than fetched live. Needs the `analytics` scope.
|
|
29
|
+
#
|
|
30
|
+
# A network whose metric access has not been granted yet answers 503
|
|
31
|
+
# (`platform_metrics_unavailable`) rather than an empty set.
|
|
32
|
+
def platform_metrics(account_id)
|
|
33
|
+
AccountPlatformMetrics.new(
|
|
34
|
+
unwrap(http.get("/accounts/#{account_id}/insights", { 'raw' => 'true' }))
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rename the account; nil or an empty string restores the platform name.
|
|
39
|
+
def update(account_id, display_name:)
|
|
40
|
+
body = { 'display_name' => display_name }
|
|
41
|
+
AccountRename.new(unwrap(http.request(:patch, "/accounts/#{account_id}", json: body)))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Move the account to another workspace the caller owns.
|
|
45
|
+
def move(account_id, workspace_id:)
|
|
46
|
+
AccountMove.new(unwrap(http.post("/accounts/#{account_id}/move", { 'workspace_id' => workspace_id })))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Mint a 15-minute code; sending `/connect <code>` to the bot connects that chat.
|
|
50
|
+
# workspace_id may be omitted for a key bound to one workspace.
|
|
51
|
+
def create_telegram_connect_code(workspace_id: nil)
|
|
52
|
+
TelegramConnectCode.new(unwrap(http.post('/accounts/telegram/connect-code',
|
|
53
|
+
compact_nil({ 'workspaceId' => workspace_id }))))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_telegram_connect_status(code)
|
|
57
|
+
TelegramConnectStatus.new(unwrap(http.get('/accounts/telegram/connect-code/status', { 'code' => code })))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def get_telegram_bot_commands(account_id)
|
|
61
|
+
TelegramBotCommands.new(unwrap(http.get("/accounts/#{account_id}/telegram/commands")))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Replace the chat's command menu; each entry is a hash with `command` and `description`.
|
|
65
|
+
def set_telegram_bot_commands(account_id, commands)
|
|
66
|
+
body = {
|
|
67
|
+
'commands' => commands.map do |entry|
|
|
68
|
+
{ 'command' => entry[:command] || entry['command'],
|
|
69
|
+
'description' => entry[:description] || entry['description'] }
|
|
70
|
+
end
|
|
71
|
+
}
|
|
72
|
+
TelegramBotCommands.new(unwrap(http.put("/accounts/#{account_id}/telegram/commands", body)))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delete_telegram_bot_commands(account_id)
|
|
76
|
+
TelegramBotCommands.new(unwrap(http.delete("/accounts/#{account_id}/telegram/commands")))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Subreddits the account is in, busiest first, plus its own profile page.
|
|
80
|
+
def list_reddit_subreddits(account_id)
|
|
81
|
+
parse_list(RedditSubreddit, unwrap(http.get("/accounts/#{account_id}/reddit/subreddits")))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The rules a subreddit publishes, in its own order.
|
|
85
|
+
def list_reddit_subreddit_rules(account_id, subreddit)
|
|
86
|
+
path = "/accounts/#{account_id}/reddit/subreddits/#{CGI.escape(subreddit)}/rules"
|
|
87
|
+
RedditSubredditRules.new(unwrap(http.get(path)))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Post flairs one subreddit offers; a flair id is valid only there.
|
|
91
|
+
def list_reddit_flairs(account_id, subreddit)
|
|
92
|
+
RedditFlairs.new(
|
|
93
|
+
unwrap(http.get("/accounts/#{account_id}/reddit/flairs", { 'subreddit' => subreddit }))
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Where posts go when a post names none; nil falls back to the profile page.
|
|
98
|
+
def set_reddit_default_subreddit(account_id, subreddit)
|
|
99
|
+
RedditDefaultSubreddit.new(
|
|
100
|
+
unwrap(http.put("/accounts/#{account_id}/reddit/default-subreddit", { 'subreddit' => subreddit }))
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Channels the Slack app can post to in the connected workspace.
|
|
105
|
+
def list_slack_channels(account_id)
|
|
106
|
+
parse_list(SlackChannel, unwrap(http.get("/accounts/#{account_id}/slack/channels")))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# People in the connected Slack workspace; a member's `id` is the handle for starting a DM.
|
|
110
|
+
def list_slack_members(account_id)
|
|
111
|
+
parse_list(SlackMember, unwrap(http.get("/accounts/#{account_id}/slack/members")))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def get_slack_identity(account_id)
|
|
115
|
+
SlackIdentity.new(unwrap(http.get("/accounts/#{account_id}/slack/identity")))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Set the posting name and icon: an omitted keyword keeps a field, nil clears it.
|
|
119
|
+
def update_slack_identity(account_id, username: UNSET, icon_url: UNSET, icon_emoji: UNSET)
|
|
120
|
+
body = compact_unset({ 'username' => username, 'icon_url' => icon_url, 'icon_emoji' => icon_emoji })
|
|
121
|
+
SlackIdentity.new(unwrap(http.request(:patch, "/accounts/#{account_id}/slack/identity", json: body)))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── Meta messaging settings (Facebook Pages, Instagram) ──────
|
|
125
|
+
|
|
126
|
+
# The prompts shown before the first message; networks without them answer 400.
|
|
127
|
+
def get_ice_breakers(account_id)
|
|
128
|
+
MetaIceBreakers.new(unwrap(http.get("/accounts/#{account_id}/messaging/ice-breakers")))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Replace the ice breakers, up to four; each entry has `question` and `payload`.
|
|
132
|
+
def set_ice_breakers(account_id, ice_breakers)
|
|
133
|
+
body = {
|
|
134
|
+
'ice_breakers' => ice_breakers.map do |entry|
|
|
135
|
+
{ 'question' => entry[:question] || entry['question'],
|
|
136
|
+
'payload' => entry[:payload] || entry['payload'] }
|
|
137
|
+
end
|
|
138
|
+
}
|
|
139
|
+
MetaIceBreakers.new(unwrap(http.put("/accounts/#{account_id}/messaging/ice-breakers", body)))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def delete_ice_breakers(account_id)
|
|
143
|
+
MetaIceBreakers.new(unwrap(http.delete("/accounts/#{account_id}/messaging/ice-breakers")))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The always-visible Messenger menu. Facebook Pages only.
|
|
147
|
+
def get_persistent_menu(account_id)
|
|
148
|
+
MetaPersistentMenu.new(unwrap(http.get("/accounts/#{account_id}/messaging/persistent-menu")))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Replace the menu, one entry per locale, up to three items each.
|
|
152
|
+
def set_persistent_menu(account_id, menu)
|
|
153
|
+
body = { 'persistent_menu' => menu.map { |entry| stringify(entry) } }
|
|
154
|
+
MetaPersistentMenu.new(unwrap(http.put("/accounts/#{account_id}/messaging/persistent-menu", body)))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def delete_persistent_menu(account_id)
|
|
158
|
+
MetaPersistentMenu.new(unwrap(http.delete("/accounts/#{account_id}/messaging/persistent-menu")))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The text shown before a Messenger conversation starts. Facebook Pages only.
|
|
162
|
+
def get_greeting(account_id)
|
|
163
|
+
MetaGreeting.new(unwrap(http.get("/accounts/#{account_id}/messaging/greeting")))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Replace the greeting, one entry per locale, each up to 160 characters.
|
|
167
|
+
def set_greeting(account_id, greeting)
|
|
168
|
+
body = {
|
|
169
|
+
'greeting' => greeting.map do |entry|
|
|
170
|
+
{ 'locale' => entry[:locale] || entry['locale'] || 'default',
|
|
171
|
+
'text' => entry[:text] || entry['text'] }
|
|
172
|
+
end
|
|
173
|
+
}
|
|
174
|
+
MetaGreeting.new(unwrap(http.put("/accounts/#{account_id}/messaging/greeting", body)))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def delete_greeting(account_id)
|
|
178
|
+
MetaGreeting.new(unwrap(http.delete("/accounts/#{account_id}/messaging/greeting")))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# What the network is delivering to the FoPost webhook for this account.
|
|
182
|
+
def get_webhook_subscription(account_id)
|
|
183
|
+
WebhookSubscription.new(unwrap(http.get("/accounts/#{account_id}/webhook-subscription")))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Subscribe to every field this account needs, lapsed or not.
|
|
187
|
+
def resubscribe_webhook(account_id)
|
|
188
|
+
WebhookSubscription.new(unwrap(http.post("/accounts/#{account_id}/webhook-subscription")))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ── Discord (bot connections; a webhook one answers 409 webhook_connection) ──
|
|
192
|
+
|
|
193
|
+
# Text channels the bot can post to in the connected server.
|
|
194
|
+
def list_discord_channels(account_id)
|
|
195
|
+
parse_list(DiscordChannel, unwrap(http.get("/accounts/#{account_id}/discord/channels")))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Move the account to another channel in the same server.
|
|
199
|
+
def switch_discord_channel(account_id, channel_id)
|
|
200
|
+
body = { 'channel_id' => channel_id }
|
|
201
|
+
path = "/accounts/#{account_id}/discord/channels/current"
|
|
202
|
+
DiscordChannel.new(unwrap(http.request(:patch, path, json: body)))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def get_discord_identity(account_id)
|
|
206
|
+
DiscordIdentity.new(unwrap(http.get("/accounts/#{account_id}/discord/identity")))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Set the bot's nickname and avatar: an omitted keyword keeps a field, nil clears it.
|
|
210
|
+
def update_discord_identity(account_id, username: UNSET, avatar_url: UNSET)
|
|
211
|
+
body = compact_unset({ 'username' => username, 'avatar_url' => avatar_url })
|
|
212
|
+
path = "/accounts/#{account_id}/discord/identity"
|
|
213
|
+
DiscordIdentity.new(unwrap(http.request(:patch, path, json: body)))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Pinned messages in the account's channel.
|
|
217
|
+
def list_discord_pins(account_id)
|
|
218
|
+
parse_list(DiscordMessage, unwrap(http.get("/accounts/#{account_id}/discord/messages/pinned")))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def delete_discord_message(account_id, message_id)
|
|
222
|
+
http.delete("/accounts/#{account_id}/discord/messages/#{message_id}")
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def pin_discord_message(account_id, message_id)
|
|
227
|
+
http.post("/accounts/#{account_id}/discord/messages/#{message_id}/pin")
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def unpin_discord_message(account_id, message_id)
|
|
232
|
+
http.delete("/accounts/#{account_id}/discord/messages/#{message_id}/pin")
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Publish an announcement-channel message to every server following it.
|
|
237
|
+
def crosspost_discord_message(account_id, message_id)
|
|
238
|
+
path = "/accounts/#{account_id}/discord/messages/#{message_id}/crosspost"
|
|
239
|
+
DiscordMessageRef.new(unwrap(http.post(path)))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Start a thread on a message; the duration is 60, 1440, 4320 or 10_080 minutes.
|
|
243
|
+
def create_discord_thread(account_id, message_id, name:, auto_archive_duration: nil)
|
|
244
|
+
body = compact_nil({ 'name' => name, 'auto_archive_duration' => auto_archive_duration })
|
|
245
|
+
path = "/accounts/#{account_id}/discord/messages/#{message_id}/thread"
|
|
246
|
+
DiscordThread.new(unwrap(http.post(path, body)))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Send one message to a member of the server.
|
|
250
|
+
def send_discord_dm(account_id, member_id, content)
|
|
251
|
+
body = { 'member_id' => member_id, 'content' => content }
|
|
252
|
+
DiscordMessageRef.new(unwrap(http.post("/accounts/#{account_id}/discord/dm", body)))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The server's scheduled events.
|
|
256
|
+
def list_discord_events(account_id)
|
|
257
|
+
parse_list(DiscordScheduledEvent, unwrap(http.get("/accounts/#{account_id}/discord/events")))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def get_discord_event(account_id, event_id)
|
|
261
|
+
DiscordScheduledEvent.new(unwrap(http.get("/accounts/#{account_id}/discord/events/#{event_id}")))
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Give a `channel_id` (a voice or stage channel), or a `location` with an `end_time`.
|
|
265
|
+
def create_discord_event(account_id, name:, start_time:, end_time: nil, description: nil,
|
|
266
|
+
channel_id: nil, location: nil)
|
|
267
|
+
body = compact_nil({
|
|
268
|
+
'name' => name, 'start_time' => start_time, 'end_time' => end_time,
|
|
269
|
+
'description' => description, 'channel_id' => channel_id, 'location' => location
|
|
270
|
+
})
|
|
271
|
+
DiscordScheduledEvent.new(unwrap(http.post("/accounts/#{account_id}/discord/events", body)))
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Only the keywords you pass are sent; Discord keeps the rest.
|
|
275
|
+
def update_discord_event(account_id, event_id, name: nil, start_time: nil, end_time: nil,
|
|
276
|
+
description: nil, channel_id: nil, location: nil, status: nil)
|
|
277
|
+
body = compact_nil({
|
|
278
|
+
'name' => name, 'start_time' => start_time, 'end_time' => end_time,
|
|
279
|
+
'description' => description, 'channel_id' => channel_id,
|
|
280
|
+
'location' => location, 'status' => status
|
|
281
|
+
})
|
|
282
|
+
path = "/accounts/#{account_id}/discord/events/#{event_id}"
|
|
283
|
+
DiscordScheduledEvent.new(unwrap(http.request(:patch, path, json: body)))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def delete_discord_event(account_id, event_id)
|
|
287
|
+
http.delete("/accounts/#{account_id}/discord/events/#{event_id}")
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The server's roster, or the members whose name starts with `query`.
|
|
292
|
+
def list_discord_members(account_id, query: nil, limit: nil)
|
|
293
|
+
params = compact_nil({ 'q' => query, 'limit' => limit })
|
|
294
|
+
parse_list(DiscordMember, unwrap(http.get("/accounts/#{account_id}/discord/members", params)))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def get_discord_member(account_id, member_id)
|
|
298
|
+
DiscordMember.new(unwrap(http.get("/accounts/#{account_id}/discord/members/#{member_id}")))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# The server's roles, highest first.
|
|
302
|
+
def list_discord_roles(account_id)
|
|
303
|
+
parse_list(DiscordRole, unwrap(http.get("/accounts/#{account_id}/discord/roles")))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def create_discord_role(account_id, name:, color: nil, hoist: nil, mentionable: nil, permissions: nil)
|
|
307
|
+
body = discord_role_body(name, color, hoist, mentionable, permissions)
|
|
308
|
+
DiscordRole.new(unwrap(http.post("/accounts/#{account_id}/discord/roles", body)))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def update_discord_role(account_id, role_id, name: nil, color: nil, hoist: nil,
|
|
312
|
+
mentionable: nil, permissions: nil)
|
|
313
|
+
body = discord_role_body(name, color, hoist, mentionable, permissions)
|
|
314
|
+
path = "/accounts/#{account_id}/discord/roles/#{role_id}"
|
|
315
|
+
DiscordRole.new(unwrap(http.request(:patch, path, json: body)))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def delete_discord_role(account_id, role_id)
|
|
319
|
+
http.delete("/accounts/#{account_id}/discord/roles/#{role_id}")
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def add_discord_member_role(account_id, role_id, member_id)
|
|
324
|
+
http.put("/accounts/#{account_id}/discord/roles/#{role_id}/members/#{member_id}")
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def remove_discord_member_role(account_id, role_id, member_id)
|
|
329
|
+
http.delete("/accounts/#{account_id}/discord/roles/#{role_id}/members/#{member_id}")
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# --- Per-network extras -------------------------------------
|
|
334
|
+
|
|
335
|
+
# Boards this Pinterest connection can pin to.
|
|
336
|
+
def list_pinterest_boards(account_id)
|
|
337
|
+
parse_list(PinterestBoard, unwrap(http.get("/accounts/#{account_id}/pinterest/boards")))
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# `privacy` is PUBLIC, PROTECTED or SECRET.
|
|
341
|
+
def create_pinterest_board(account_id, name:, description: UNSET, privacy: UNSET)
|
|
342
|
+
body = compact_unset({ 'name' => name, 'description' => description, 'privacy' => privacy })
|
|
343
|
+
PinterestBoard.new(unwrap(http.post("/accounts/#{account_id}/pinterest/boards", body)))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# The channel's own playlists, with the stored default marked.
|
|
347
|
+
def list_youtube_playlists(account_id)
|
|
348
|
+
parse_list(YouTubePlaylist, unwrap(http.get("/accounts/#{account_id}/youtube/playlists")))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def create_youtube_playlist(account_id, title:, description: UNSET, privacy: UNSET)
|
|
352
|
+
body = compact_unset({ 'title' => title, 'description' => description, 'privacy' => privacy })
|
|
353
|
+
YouTubePlaylist.new(unwrap(http.post("/accounts/#{account_id}/youtube/playlists", body)))
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# The playlist a new video joins when the post picks none; nil clears it.
|
|
357
|
+
def set_default_youtube_playlist(account_id, playlist_id)
|
|
358
|
+
data = unwrap(http.put("/accounts/#{account_id}/youtube/playlists/default",
|
|
359
|
+
{ 'playlist_id' => playlist_id }))
|
|
360
|
+
data.is_a?(Hash) ? data['playlist_id'] : nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def list_youtube_captions(account_id, video_id)
|
|
364
|
+
parse_list(YouTubeCaptionTrack,
|
|
365
|
+
unwrap(http.get("/accounts/#{account_id}/youtube/videos/#{video_id}/captions")))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# `body` is the subtitle file itself; YouTube reads SRT and WebVTT and sniffs which.
|
|
369
|
+
def upload_youtube_captions(account_id, video_id, language:, body:, name: UNSET, is_draft: UNSET)
|
|
370
|
+
payload = compact_unset({ 'language' => language, 'body' => body, 'name' => name,
|
|
371
|
+
'is_draft' => is_draft })
|
|
372
|
+
YouTubeCaptionTrack.new(
|
|
373
|
+
unwrap(http.post("/accounts/#{account_id}/youtube/videos/#{video_id}/captions", payload))
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def read_youtube_transcript(account_id, caption_id)
|
|
378
|
+
YouTubeTranscript.new(unwrap(http.get("/accounts/#{account_id}/youtube/captions/#{caption_id}")))
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# What a post from this connection is written in when it does not say.
|
|
382
|
+
def get_bluesky_languages(account_id)
|
|
383
|
+
BlueskyLanguages.new(unwrap(http.get("/accounts/#{account_id}/bluesky/languages")))
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Up to three BCP-47 tags; an empty list clears the default.
|
|
387
|
+
def set_bluesky_languages(account_id, languages)
|
|
388
|
+
BlueskyLanguages.new(
|
|
389
|
+
unwrap(http.put("/accounts/#{account_id}/bluesky/languages", { 'languages' => Array(languages) }))
|
|
390
|
+
)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# The switches TikTok enforces at publish time, changed in the TikTok app.
|
|
394
|
+
def get_tiktok_creator_info(account_id)
|
|
395
|
+
TikTokCreatorInfo.new(unwrap(http.get("/accounts/#{account_id}/tiktok/creator-info")))
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# TikTok's Commercial Music Library. Needs the Marketing API product on
|
|
399
|
+
# the TikTok app; without it the call raises rather than answering empty.
|
|
400
|
+
def search_tiktok_music(account_id, q:, limit: nil)
|
|
401
|
+
params = { 'q' => q, 'limit' => limit }.compact
|
|
402
|
+
parse_list(TikTokMusic, unwrap(http.get("/accounts/#{account_id}/tiktok/music", params)))
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Places a post can be tagged with. Same TikTok product as the music library.
|
|
406
|
+
def search_tiktok_locations(account_id, q:, limit: nil)
|
|
407
|
+
params = { 'q' => q, 'limit' => limit }.compact
|
|
408
|
+
parse_list(TikTokPlace, unwrap(http.get("/accounts/#{account_id}/tiktok/locations", params)))
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Resolve a share link to one of this account's own videos, for repurposing.
|
|
412
|
+
def lookup_tiktok_video(account_id, url)
|
|
413
|
+
TikTokVideoSource.new(
|
|
414
|
+
unwrap(http.post("/accounts/#{account_id}/tiktok/video-download", { 'url' => url }))
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Tracks a Reel can carry; with no query Instagram answers with what is trending.
|
|
419
|
+
def search_instagram_audio(account_id, q: nil, audio_type: nil)
|
|
420
|
+
params = { 'q' => q, 'audio_type' => audio_type }.compact
|
|
421
|
+
parse_list(InstagramAudio, unwrap(http.get("/accounts/#{account_id}/instagram/audio", params)))
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# How many posts are left before Instagram refuses the next one.
|
|
425
|
+
def get_instagram_publishing_limit(account_id)
|
|
426
|
+
InstagramPublishingLimit.new(unwrap(http.get("/accounts/#{account_id}/instagram/publishing-limit")))
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Stories still inside their 24 hours, posted through FoPost or not.
|
|
430
|
+
def list_instagram_stories(account_id, insights: nil)
|
|
431
|
+
params = insights.nil? ? {} : { 'insights' => insights }
|
|
432
|
+
parse_list(InstagramStory, unwrap(http.get("/accounts/#{account_id}/instagram/stories", params)))
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def get_instagram_story_insights(account_id, story_id)
|
|
436
|
+
InstagramStoryInsights.new(
|
|
437
|
+
unwrap(http.get("/accounts/#{account_id}/instagram/stories/#{story_id}/insights"))
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Organizations a LinkedIn post can mention. People are not searchable.
|
|
442
|
+
def search_linkedin_mentions(account_id, q)
|
|
443
|
+
parse_list(LinkedInMention, unwrap(http.get("/accounts/#{account_id}/linkedin/mentions", { 'q' => q })))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
private
|
|
447
|
+
|
|
448
|
+
# Symbol keys read the same as string keys on the way out to the API.
|
|
449
|
+
def stringify(value)
|
|
450
|
+
case value
|
|
451
|
+
when Hash then value.to_h { |k, v| [k.to_s, stringify(v)] }
|
|
452
|
+
when Array then value.map { |v| stringify(v) }
|
|
453
|
+
else value
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def discord_role_body(name, color, hoist, mentionable, permissions)
|
|
458
|
+
compact_nil({
|
|
459
|
+
'name' => name, 'color' => color, 'hoist' => hoist,
|
|
460
|
+
'mentionable' => mentionable, 'permissions' => permissions
|
|
461
|
+
})
|
|
462
|
+
end
|
|
21
463
|
end
|
|
22
464
|
end
|
|
23
465
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fopost
|
|
4
|
+
module Resources
|
|
5
|
+
# `client.activity` — what happened in a workspace, and the audit log.
|
|
6
|
+
class Activity < Base
|
|
7
|
+
# Newest first. Omit `workspace_id` to read every workspace the key can reach.
|
|
8
|
+
#
|
|
9
|
+
# `kind: 'security'` is the audit log: members joining, leaving or changing
|
|
10
|
+
# role and access, and changes to two-step verification, passkeys, single
|
|
11
|
+
# sign-on and signed-in devices. Those rows are append-only and never expire.
|
|
12
|
+
def list(workspace_id: nil, kind: nil, from: nil, to: nil, cursor: nil, limit: nil)
|
|
13
|
+
params = compact_nil(
|
|
14
|
+
'workspace_id' => workspace_id,
|
|
15
|
+
'kind' => kind,
|
|
16
|
+
'from' => iso8601(from),
|
|
17
|
+
'to' => iso8601(to),
|
|
18
|
+
'cursor' => cursor,
|
|
19
|
+
'limit' => limit
|
|
20
|
+
)
|
|
21
|
+
body = as_hash(http.get('/activity', params))
|
|
22
|
+
meta = body['meta'].is_a?(Hash) ? body['meta'] : {}
|
|
23
|
+
ActivityPage.new('events' => body['data'], 'next_cursor' => meta['next_cursor'])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|