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.
@@ -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 query param; posts and labels use snake.
10
- parse_list(SocialAccount, unwrap(http.get('/accounts', { 'workspaceId' => workspace_id })))
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