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/README.md
CHANGED
|
@@ -64,6 +64,21 @@ client.posts.create(
|
|
|
64
64
|
|
|
65
65
|
`accounts` takes account ids, the `Fopost::SocialAccount` objects themselves, or hashes with an `id`.
|
|
66
66
|
|
|
67
|
+
### Files on a post
|
|
68
|
+
|
|
69
|
+
`files` attaches up to 5 files to the first block. Each is a path, an IO, or a hash of `filename:` plus `io:` or `data:` and `mime_type:` (a path or `File` guesses the type from its extension). The files land in the workspace library; the SDK picks the route by size and the result is the same either way: files at or under the multipart ceiling (`media.limits.multipart_max_file_bytes`, 100 MB) go in one multipart request, anything larger goes through direct upload first. `posts.update(id, files: ...)` appends to the first block the same way.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
client.posts.create(
|
|
73
|
+
workspace_id: workspace.id,
|
|
74
|
+
content: 'Launch day',
|
|
75
|
+
accounts: accounts.map(&:id),
|
|
76
|
+
files: ['launch.png', { filename: 'teaser.mp4', io: File.open('teaser.mp4', 'rb'), mime_type: 'video/mp4' }]
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A file over the account's ceiling raises `Fopost::FileTooLargeError` (413); `max_file_bytes` is the largest the account may upload and `upgrade_url` is where a bigger one is unlocked.
|
|
81
|
+
|
|
67
82
|
## Scheduling
|
|
68
83
|
|
|
69
84
|
`status` is `"draft"` or `"scheduled"`; a scheduled post needs `schedule_at`. To send something out now, create it and call `publish`.
|
|
@@ -102,6 +117,99 @@ client.posts.update(post_id, schedule_at: nil) # sends {"schedule_at": null}
|
|
|
102
117
|
client.posts.update(post_id, title: 'Renamed') # sends {"title": "Renamed"}
|
|
103
118
|
```
|
|
104
119
|
|
|
120
|
+
## Account groups
|
|
121
|
+
|
|
122
|
+
Name a set of accounts once and post to all of them. Needs the `accounts` scope.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
group = client.account_groups.create(workspace_id: workspace.id, name: 'Launch', account_ids: %w[acc_1 acc_2])
|
|
126
|
+
client.account_groups.list(workspace_id: workspace.id)
|
|
127
|
+
client.account_groups.update(group.id, name: 'Launch week')
|
|
128
|
+
client.account_groups.set_members(group.id, %w[acc_1 acc_3]) # replaces the members
|
|
129
|
+
client.account_groups.delete(group.id)
|
|
130
|
+
|
|
131
|
+
client.posts.create(workspace_id: workspace.id, content: 'Hello', account_group_id: group.id)
|
|
132
|
+
client.accounts.list(group_id: group.id)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Accounts can also be renamed and moved between workspaces you own:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
client.accounts.update(account_id, display_name: 'Brand HQ') # nil restores the platform name
|
|
139
|
+
client.accounts.move(account_id, workspace_id: other_workspace.id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Connect a Telegram chat with a one-time code, and set the command menu the bot shows there:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
code = client.accounts.create_telegram_connect_code(workspace_id: workspace.id)
|
|
146
|
+
puts code.command # send this to the bot in the chat
|
|
147
|
+
client.accounts.get_telegram_connect_status(code.code).status # pending, connected, failed or expired
|
|
148
|
+
|
|
149
|
+
client.accounts.set_telegram_bot_commands(account_id, [{ command: 'start', description: 'Start here' }])
|
|
150
|
+
client.accounts.get_telegram_bot_commands(account_id)
|
|
151
|
+
client.accounts.delete_telegram_bot_commands(account_id)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Read a Slack account's channels and members, and set the name and icon it posts under. A member's `id` is the
|
|
155
|
+
handle for `inbox.start_conversation`:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
client.accounts.list_slack_channels(account_id)
|
|
159
|
+
client.accounts.list_slack_members(account_id)
|
|
160
|
+
client.accounts.get_slack_identity(account_id)
|
|
161
|
+
client.accounts.update_slack_identity(account_id, username: 'Launch Bot', icon_emoji: ':rocket:') # nil clears
|
|
162
|
+
|
|
163
|
+
# Meta messaging settings. Ice breakers on Facebook Pages and Instagram; the menu and
|
|
164
|
+
# greeting on Pages only. A network without a field answers 400.
|
|
165
|
+
client.accounts.set_ice_breakers(account_id, [{ question: 'What are your hours?', payload: 'HOURS' }])
|
|
166
|
+
client.accounts.set_persistent_menu(account_id, [{ locale: 'default',
|
|
167
|
+
call_to_actions: [{ type: 'postback', title: 'Talk to Us',
|
|
168
|
+
payload: 'HUMAN' }] }])
|
|
169
|
+
client.accounts.set_greeting(account_id, [{ text: 'Hi! Ask us anything.' }])
|
|
170
|
+
|
|
171
|
+
# Is the network still delivering events for this account?
|
|
172
|
+
subscription = client.accounts.get_webhook_subscription(account_id)
|
|
173
|
+
client.accounts.resubscribe_webhook(account_id) unless subscription.subscribed
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On a Discord bot connection, read and change the channel it posts to and manage the server itself:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
client.accounts.list_discord_channels(account_id)
|
|
180
|
+
client.accounts.switch_discord_channel(account_id, 'c2')
|
|
181
|
+
client.accounts.update_discord_identity(account_id, username: 'Release Bot') # nil clears
|
|
182
|
+
|
|
183
|
+
client.accounts.create_discord_event(account_id, name: 'Launch stream',
|
|
184
|
+
start_time: '2026-10-01T18:00:00Z',
|
|
185
|
+
end_time: '2026-10-01T19:00:00Z',
|
|
186
|
+
location: 'https://yourbrand.com/live')
|
|
187
|
+
|
|
188
|
+
members = client.accounts.list_discord_members(account_id, query: 'ada')
|
|
189
|
+
role = client.accounts.create_discord_role(account_id, name: 'Beta')
|
|
190
|
+
client.accounts.add_discord_member_role(account_id, role.id, members[0].id)
|
|
191
|
+
client.accounts.send_discord_dm(account_id, members[0].id, 'Welcome aboard')
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Read the numbers only an account's own network reports, in its own vocabulary — ad-break earnings, story
|
|
195
|
+
taps, a retention curve, the search terms behind a listing. A network whose metric access has not been
|
|
196
|
+
granted yet answers `503`:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
metrics = client.accounts.platform_metrics(account_id)
|
|
200
|
+
metrics.account.metrics.each { |row| puts "#{row.label}: #{row.value}" }
|
|
201
|
+
metrics.post.external_post_id
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
On Reddit, where a post can go and what that subreddit expects of it:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
client.accounts.list_reddit_subreddits(account_id)
|
|
208
|
+
client.accounts.list_reddit_subreddit_rules(account_id, 'webdev')
|
|
209
|
+
client.accounts.list_reddit_flairs(account_id, 'webdev')
|
|
210
|
+
client.accounts.set_reddit_default_subreddit(account_id, 'webdev') # nil falls back to the profile page
|
|
211
|
+
```
|
|
212
|
+
|
|
105
213
|
## Pagination
|
|
106
214
|
|
|
107
215
|
`posts.list` returns one page, which is `Enumerable` over its items. `posts.each` walks every page for you.
|
|
@@ -152,6 +260,75 @@ repurposed = client.ai.repurpose_url(
|
|
|
152
260
|
|
|
153
261
|
> **API keys reach `credits` and `generate_caption`.** `rewrite` and `repurpose_url` currently require a signed-in dashboard session and answer `401` to an API key. They are here so the surface is complete once the server opens them up.
|
|
154
262
|
|
|
263
|
+
## Validate
|
|
264
|
+
|
|
265
|
+
Check a draft before you create a post. Needs the `posts` scope; nothing is stored.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
result = client.validate.post(content: 'Hello', platforms: %w[twitter linkedin],
|
|
269
|
+
media: [{ url: 'https://example.com/a.png', mime_type: 'image/png' }])
|
|
270
|
+
result.platforms.reject(&:ready).each { |p| puts "#{p.platform}: #{p.issues.join(', ')}" }
|
|
271
|
+
|
|
272
|
+
lengths = client.validate.length(text: 'Hello', platforms: %w[twitter bluesky])
|
|
273
|
+
lengths.platforms.each { |p| puts "#{p.platform}: #{p.length}/#{p.limit || 'no limit'} #{p.unit}" }
|
|
274
|
+
|
|
275
|
+
file = client.validate.media(url: 'https://example.com/a.png')
|
|
276
|
+
puts file.ok ? file.type : file.issues.join(', ')
|
|
277
|
+
|
|
278
|
+
sub = client.validate.subreddit(account_id: account_id, name: 'webdev')
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Activity
|
|
282
|
+
|
|
283
|
+
What happened in a workspace, newest first. Needs the `analytics` scope.
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
page = client.activity.list(workspace_id: workspace.id)
|
|
287
|
+
page.events.each { |event| puts "#{event.time} #{event.actor.name}: #{event.summary}" }
|
|
288
|
+
page.next_cursor # pass back as cursor: for the next page
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`kind: 'security'` is the audit log: members joining, leaving or changing role
|
|
292
|
+
and access, and changes to two-step verification, passkeys, single sign-on and
|
|
293
|
+
signed-in devices. Those rows are append-only and never expire.
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
audit = client.activity.list(workspace_id: workspace.id, kind: 'security')
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Blogs, articles and products
|
|
300
|
+
|
|
301
|
+
Content a connected site already owns: the articles on a WordPress site or a
|
|
302
|
+
Shopify store's blog, and a Shopify store's products. Every id here is the
|
|
303
|
+
platform's own, never a FoPost id. Reads need the `posts` scope; anything that
|
|
304
|
+
changes the site needs `publish` as well.
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
blogs = client.blogs.list_blogs(account_id)
|
|
308
|
+
# Shopify reports every blog; WordPress reports one, under the id 'default'.
|
|
309
|
+
|
|
310
|
+
articles = client.blogs.list_articles(account_id, blogs.first.id, status: 'draft')
|
|
311
|
+
articles.first.title
|
|
312
|
+
articles.first.status # published, draft, pending, scheduled
|
|
313
|
+
|
|
314
|
+
article = client.blogs.create_article(
|
|
315
|
+
account_id, blogs.first.id,
|
|
316
|
+
title: 'Spring drop',
|
|
317
|
+
body: 'The new collection is live.',
|
|
318
|
+
status: 'draft',
|
|
319
|
+
tags: ['news']
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Changes the live article in place: only what you name is touched, and the
|
|
323
|
+
# article is addressed by its own id, so this never leaves a duplicate behind.
|
|
324
|
+
client.blogs.update_article(account_id, blogs.first.id, article.id, status: 'published')
|
|
325
|
+
|
|
326
|
+
client.blogs.delete_article(account_id, blogs.first.id, article.id)
|
|
327
|
+
|
|
328
|
+
products = client.blogs.list_products(account_id, status: 'active')
|
|
329
|
+
client.blogs.update_product(account_id, products.first.id, title: 'Mug XL')
|
|
330
|
+
```
|
|
331
|
+
|
|
155
332
|
## Inbox
|
|
156
333
|
|
|
157
334
|
Comments, mentions and direct messages on connected accounts. Needs the `inbox` scope.
|
|
@@ -174,12 +351,211 @@ client.inbox.refresh(workspace_id: workspace.id)
|
|
|
174
351
|
client.inbox.approvals(workspace_id: workspace.id).each { |a| client.inbox.approve_reply(a.id) }
|
|
175
352
|
```
|
|
176
353
|
|
|
354
|
+
Acting on the platform also needs the `publish` scope. Each item's `can_*` flags say which actions its network supports.
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
client.inbox.like(item.id) # unlike, pin, unpin work the same way
|
|
358
|
+
client.inbox.vote(item.id, 'down') # 'up', 'down', or 'none' to take it back
|
|
359
|
+
client.inbox.react(item.id, reaction: '❤️') # reaction: nil removes ours
|
|
360
|
+
client.inbox.edit_comment(item.id, text: 'Fixed a typo')
|
|
361
|
+
client.inbox.reply(item.id, media_ids: [media.id], quick_replies: %w[Yes No])
|
|
362
|
+
client.inbox.start_conversation(account_id: account.id, handle: 'jordanvale', text: 'Hi!')
|
|
363
|
+
client.inbox.start_conversation(comment_id: item.id, text: 'Sent you the details')
|
|
364
|
+
client.inbox.set_typing(item.conversation_id, account_id: item.account.id)
|
|
365
|
+
|
|
366
|
+
# Messenger hand-over: pass the thread to another Meta app, or take it back with no app id.
|
|
367
|
+
client.inbox.handover(item.conversation_id, account_id: item.account.id, app_id: '263902037430900')
|
|
368
|
+
client.inbox.handover(item.conversation_id, account_id: item.account.id)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Contacts
|
|
372
|
+
|
|
373
|
+
The people behind the inbox. A contact is one human however many handles they write from: an inbound item files its author, a reply files whoever you answered, and both fold into whatever is already on file. Needs the `inbox` scope.
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
page = client.contacts.list(workspace_id: workspace_id, search: 'ada')
|
|
377
|
+
page.each { |contact| puts "#{contact.display_name} — #{contact.channels.size} handles" }
|
|
378
|
+
puts page.meta.total
|
|
379
|
+
|
|
380
|
+
contact = client.contacts.get(contact_id)
|
|
381
|
+
|
|
382
|
+
# Folds into whoever already holds the first channel, so this cannot duplicate someone.
|
|
383
|
+
contact = client.contacts.create(
|
|
384
|
+
workspace_id: workspace_id,
|
|
385
|
+
channels: [{ 'platform' => 'x', 'handle' => 'ada_writes' }],
|
|
386
|
+
display_name: 'Ada Okafor',
|
|
387
|
+
fields: { 'plan_tier' => 'Pro' }
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
client.contacts.update(contact.id, fields: { 'region' => nil }) # nil clears a field
|
|
391
|
+
client.contacts.delete(contact.id) # the messages stay
|
|
392
|
+
|
|
393
|
+
# The threads this person appears in, newest first.
|
|
394
|
+
client.contacts.conversations(contact.id).each do |thread|
|
|
395
|
+
puts "#{thread.platform} #{thread.messages} messages, #{thread.received} in"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# platform and handle are required columns; any other column is a custom field key.
|
|
399
|
+
result = client.contacts.import(workspace_id: workspace_id, csv: "platform,handle\nx,ada_writes")
|
|
400
|
+
puts "#{result.created} created, #{result.merged} merged"
|
|
401
|
+
puts result.unknown_columns.inspect
|
|
402
|
+
|
|
403
|
+
# The columns your workspace keeps.
|
|
404
|
+
fields = client.contacts.list_fields(workspace_id)
|
|
405
|
+
field = client.contacts.create_field(workspace_id: workspace_id, key: 'plan_tier',
|
|
406
|
+
name: 'Plan Tier', type: 'select', options: %w[Free Pro])
|
|
407
|
+
client.contacts.update_field(field.id, name: 'Tier')
|
|
408
|
+
client.contacts.delete_field(field.id) # removes every answer to it
|
|
409
|
+
|
|
410
|
+
# Volume and median reply time per thread. Needs the `analytics` scope.
|
|
411
|
+
report = client.contacts.conversation_analytics(days: 30, sort: 'slowest')
|
|
412
|
+
puts report.conversations.first.median_response_minutes
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Broadcasts
|
|
416
|
+
|
|
417
|
+
One message into every conversation you already have with a segment of your contacts. Nothing is sent into a closed messaging window: Messenger and Instagram take a business-initiated message only within 24 hours of the contact's last one, so recipients outside it come back skipped with `window_closed` rather than attempted. Telegram, Slack, Bluesky and Reddit have no window.
|
|
418
|
+
|
|
419
|
+
Reading needs the `inbox` scope; `send` and `cancel` also need `publish`.
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
page = client.broadcasts.list(workspace_id: workspace_id, status: 'sent')
|
|
423
|
+
page.each { |b| puts "#{b.name} — #{b.counts.sent} sent, #{b.counts.skipped} skipped" }
|
|
424
|
+
|
|
425
|
+
broadcast = client.broadcasts.create(
|
|
426
|
+
workspace_id: workspace_id,
|
|
427
|
+
account_id: account_id,
|
|
428
|
+
name: 'September check-in',
|
|
429
|
+
text: 'New colours just landed. Want a look?',
|
|
430
|
+
audience: { 'platforms' => ['instagram'] }
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# The recipients count is how many contacts matched, not how many will be
|
|
434
|
+
# messaged — the messaging window decides that.
|
|
435
|
+
client.broadcasts.send(broadcast.id)
|
|
436
|
+
|
|
437
|
+
# Who was skipped, and why.
|
|
438
|
+
client.broadcasts.recipients(broadcast.id, status: 'skipped').each do |r|
|
|
439
|
+
puts "#{r.display_name}: #{r.skip_reason}"
|
|
440
|
+
end
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## Sequences
|
|
444
|
+
|
|
445
|
+
A series of messages, each a delay after the one before, walked per enrolled contact. The messaging window applies to every step: one that comes due outside it is skipped rather than sent, and the enrollment carries on.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
sequence = client.sequences.create(
|
|
449
|
+
workspace_id: workspace_id,
|
|
450
|
+
account_id: account_id,
|
|
451
|
+
name: 'Welcome',
|
|
452
|
+
steps: [
|
|
453
|
+
{ 'delay_hours' => 0, 'text' => 'Thanks for the follow — anything I can help with?' },
|
|
454
|
+
{ 'delay_hours' => 48, 'text' => 'Here is what people usually ask us first.' }
|
|
455
|
+
]
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# By id, or by the same audience filter a broadcast takes.
|
|
459
|
+
client.sequences.enroll(sequence.id, contact_ids: [contact_id])
|
|
460
|
+
client.sequences.enroll(sequence.id, audience: { 'platforms' => ['telegram'] })
|
|
461
|
+
|
|
462
|
+
# Nothing further fires for them.
|
|
463
|
+
client.sequences.unenroll(sequence.id, [contact_id])
|
|
464
|
+
|
|
465
|
+
client.sequences.enrollments(sequence.id).each do |e|
|
|
466
|
+
puts "#{e.display_name} — step #{e.step}, #{e.status}"
|
|
467
|
+
end
|
|
468
|
+
```
|
|
469
|
+
## Blog
|
|
470
|
+
|
|
471
|
+
Articles written for a website, as opposed to posts that go to a social network. Reads, settings, keyword edits and `submit_article` need the `posts` scope; `suggest_seeds`, `plan_keywords`, `request_article` and `regenerate_article` spend credits and need `ai`; `publish_article` needs `publish`.
|
|
472
|
+
|
|
473
|
+
Planning starts from the blog's goal. Suggest search terms, research them, then pick the keywords worth writing; a picked keyword takes the next free date on the calendar.
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
client.blog.update_settings(workspace_id, goal: 'leads', focus_topics: 'Trail running gear')
|
|
477
|
+
|
|
478
|
+
seeds = client.blog.suggest_seeds(workspace_id, prompt: 'Beginners, not racers')
|
|
479
|
+
client.blog.plan_keywords(workspace_id, seeds: seeds)
|
|
480
|
+
client.blog.get_settings(workspace_id).plan_run&.status # => "done"
|
|
481
|
+
|
|
482
|
+
client.blog.list_keywords(workspace_id, status: 'suggested').first(3).each do |keyword|
|
|
483
|
+
client.blog.update_keyword(workspace_id, keyword.id, status: 'planned')
|
|
484
|
+
end
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Every article is approved before it publishes: submit a draft for review, then publish it or give it a `publish_at`.
|
|
488
|
+
|
|
489
|
+
```ruby
|
|
490
|
+
article = client.blog.request_article(
|
|
491
|
+
workspace_id,
|
|
492
|
+
keyword: 'best trail running shoes',
|
|
493
|
+
instructions: 'Lead with a comparison table.'
|
|
494
|
+
)
|
|
495
|
+
article.status # => "queued"
|
|
496
|
+
|
|
497
|
+
# Later, once it is a draft.
|
|
498
|
+
full = client.blog.get_article(workspace_id, article.id)
|
|
499
|
+
puts full.body_markdown
|
|
500
|
+
client.blog.submit_article(workspace_id, article.id) # => status "in_review"
|
|
501
|
+
client.blog.publish_article(workspace_id, article.id)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The Website target delivers every published article to `website_url` as a signed webhook. Setting the URL answers `website_secret` once; verify the raw request bytes with it, never a re-serialised body:
|
|
505
|
+
|
|
506
|
+
```ruby
|
|
507
|
+
# Rails: request.raw_post and request.headers; Rack: env['rack.input'].read and env
|
|
508
|
+
event = Fopost::Webhooks.verify(raw_body, headers, ENV['FOPOST_WEBSITE_SECRET'])
|
|
509
|
+
if event.event == 'article.published'
|
|
510
|
+
article = event.article # Fopost::BlogArticlePublished
|
|
511
|
+
store(article.slug, article.body_html, article.featured_image&.url)
|
|
512
|
+
end
|
|
513
|
+
rescue Fopost::WebhookSignatureError
|
|
514
|
+
head :unauthorized
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
`verify` prefers the timestamped `X-FoPost-Signature-256` header and rejects a timestamp more than 300 seconds off; it falls back to `X-FoPost-Signature` only when no timestamp was sent. The same helper verifies every other FoPost webhook.
|
|
518
|
+
|
|
519
|
+
## Knowledge
|
|
520
|
+
|
|
521
|
+
What the workspace has told FoPost about itself. Retrieval over these sources
|
|
522
|
+
is what grounds a drafted inbox reply in your own answers instead of an
|
|
523
|
+
invented one. Needs the `inbox` scope.
|
|
524
|
+
|
|
525
|
+
```ruby
|
|
526
|
+
# A source is an FAQ, a note, a page on your own site, or a plain-text/CSV
|
|
527
|
+
# media item. Adding one queues it for indexing, so it comes back `pending`.
|
|
528
|
+
faq = client.knowledge.create(
|
|
529
|
+
kind: 'faq',
|
|
530
|
+
title: 'Refunds and returns',
|
|
531
|
+
content: "Q: How long do refunds take?\nA: Up to 30 days from the request.",
|
|
532
|
+
workspace_id: workspace.id,
|
|
533
|
+
)
|
|
534
|
+
page = client.knowledge.create(kind: 'url', title: 'Shipping', url: 'https://yourbrand.com/shipping')
|
|
535
|
+
|
|
536
|
+
client.knowledge.list(workspace_id: workspace.id).each do |source|
|
|
537
|
+
puts "#{source.title} #{source.status} #{source.chunk_count}"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Editing the text or the URL re-indexes the source on its own; a page you
|
|
541
|
+
# changed on your own site needs an explicit re-read.
|
|
542
|
+
client.knowledge.update(faq.id, title: 'Refunds')
|
|
543
|
+
client.knowledge.sync(page.id)
|
|
544
|
+
client.knowledge.delete(page.id)
|
|
545
|
+
|
|
546
|
+
# Empty is the honest answer when nothing stored answers the question.
|
|
547
|
+
client.knowledge.search('how long do refunds take?', top_k: 3).each do |match|
|
|
548
|
+
puts "#{match.source_title}: #{match.text}"
|
|
549
|
+
end
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
|
|
177
553
|
## Ads
|
|
178
554
|
|
|
179
|
-
|
|
555
|
+
Ads, campaigns, creatives, audiences, insights and lead forms on a connected network. `providers` lists the networks a deployment knows and what each supports. Every call needs the `ads` scope; `boost`, `create`, `set_status`, `delete`, `bulk_set_status` and the create, update, delete and duplicate calls for campaigns, ad sets and network ads spend money and also need `publish`. A boost, campaign, ad set or ad starts paused unless you pass `paused: false`.
|
|
180
556
|
|
|
181
557
|
```ruby
|
|
182
|
-
url = client.ads.
|
|
558
|
+
url = client.ads.authorize('meta', workspace_id: workspace.id) # finish the login in a browser
|
|
183
559
|
source = client.ads.sources(workspace_id: workspace.id).first
|
|
184
560
|
|
|
185
561
|
ad = client.ads.boost(
|
|
@@ -205,6 +581,176 @@ client.ads.lead_forms(workspace_id: workspace.id)
|
|
|
205
581
|
client.ads.leads('form_1', connection_id: source.connection_id, page_id: '42')
|
|
206
582
|
```
|
|
207
583
|
|
|
584
|
+
The campaign tree is read live from Meta by Meta id, so each call names the connection:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
conn = source.connection_id
|
|
588
|
+
tree = client.ads.account_tree('act_123', connection_id: conn, workspace_id: workspace.id)
|
|
589
|
+
campaign = client.ads.create_campaign(workspace_id: workspace.id, connection_id: conn, ad_account_id: 'act_123',
|
|
590
|
+
name: 'Spring', goal: 'traffic')
|
|
591
|
+
ad_set = client.ads.create_ad_set(workspace_id: workspace.id, connection_id: conn, campaign_id: campaign.id,
|
|
592
|
+
page_id: '42', name: 'US adults', goal: 'traffic',
|
|
593
|
+
budget: { minor: 5000, type: 'daily' },
|
|
594
|
+
targeting: { countries: ['US'], ageMin: 18, ageMax: 65, gender: 'all' })
|
|
595
|
+
creative = client.ads.create_creative(workspace_id: workspace.id, connection_id: conn, ad_account_id: 'act_123',
|
|
596
|
+
page_id: '42', name: 'Hero', format: 'image', text: 'New in store',
|
|
597
|
+
media_url: 'https://yourbrand.com/hero.jpg', url_tags: 'utm_source=meta')
|
|
598
|
+
client.ads.create_network_ad(workspace_id: workspace.id, connection_id: conn, ad_set_id: ad_set.id,
|
|
599
|
+
creative_id: creative.id, name: 'Hero ad')
|
|
600
|
+
client.ads.bulk_set_status(workspace_id: workspace.id, connection_id: conn, status: 'active',
|
|
601
|
+
objects: [{ id: campaign.id, level: 'campaign' }])
|
|
602
|
+
|
|
603
|
+
client.ads.insights(connection_id: conn, object_id: campaign.id, since: '2026-09-01', until: '2026-09-07',
|
|
604
|
+
breakdown: 'age', daily: true)
|
|
605
|
+
client.ads.ad_insights(ad.id, workspace_id: workspace.id, since: '2026-09-01', until: '2026-09-07')
|
|
606
|
+
|
|
607
|
+
client.ads.subscribe_lead_page(workspace_id: workspace.id, connection_id: conn, page_id: '42')
|
|
608
|
+
page = client.ads.leads_feed(workspace_id: workspace.id, limit: 50)
|
|
609
|
+
page = client.ads.leads_feed(workspace_id: workspace.id, cursor: page.next_cursor) if page.next_cursor
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
| Group | Methods |
|
|
613
|
+
| --- | --- |
|
|
614
|
+
| Campaigns | `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign` |
|
|
615
|
+
| Ad sets | `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set` |
|
|
616
|
+
| Network ads | `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status` |
|
|
617
|
+
| Creatives | `creatives`, `create_creative`, `get_creative`, `delete_creative` |
|
|
618
|
+
| Networks | `providers`, `authorize` |
|
|
619
|
+
| Audiences | `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach` |
|
|
620
|
+
| Forecasts | `bid_pricing`, `supply_forecast` |
|
|
621
|
+
| Conversions | `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` |
|
|
622
|
+
| Ad library | `ad_library` |
|
|
623
|
+
| Insights | `insights`, `ad_insights` |
|
|
624
|
+
| Leads | `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page` |
|
|
625
|
+
| Goals | `goals` |
|
|
626
|
+
| Catalogs | `catalogs`, `create_catalog`, `catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set` |
|
|
627
|
+
| Reach and frequency | `reach_frequency`, `create_reach_frequency`, `reach_frequency_prediction`, `reserve_reach_frequency`, `cancel_reach_frequency` |
|
|
628
|
+
| Ad Library | `library` |
|
|
629
|
+
| Partnership ads | `partnership_creators`, `request_partnership`, `revoke_partnership` |
|
|
630
|
+
| Account settings | `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set` |
|
|
631
|
+
|
|
632
|
+
## Google Business Profile
|
|
633
|
+
|
|
634
|
+
Manage a connected Business Profile location: the profile, attributes, food menus, services, photos, action links, verification and performance.
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
location = client.google_business.get_location('acc_1')
|
|
638
|
+
client.google_business.update_location('acc_1', title: 'Corner Bakery', website_uri: 'https://yourbrand.com')
|
|
639
|
+
|
|
640
|
+
# Photos come from your media library, JPEG or PNG.
|
|
641
|
+
client.google_business.add_media('acc_1', media_id: media_id, category: 'INTERIOR')
|
|
642
|
+
|
|
643
|
+
client.google_business.create_place_action('acc_1', uri: 'https://yourbrand.com/book',
|
|
644
|
+
place_action_type: 'APPOINTMENT')
|
|
645
|
+
|
|
646
|
+
metrics = client.google_business.get_performance('acc_1', start_date: '2026-09-01', end_date: '2026-09-30')
|
|
647
|
+
terms = client.google_business.get_search_keywords('acc_1', start_date: '2026-08-01', end_date: '2026-09-01')
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
Responses relay Google's own shape as plain hashes. Reads need the `accounts` scope, writes `publish` as well. Every call raises a 503 `configuration_error` until Google grants the deployment Business Profile API access.
|
|
651
|
+
|
|
652
|
+
## Media
|
|
653
|
+
|
|
654
|
+
`limits` reads the upload ceilings for the account: `max_file_bytes` is the largest file by any route (100 MB up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15, 5 GB beyond, or 5 GB with the Large Uploads add-on), `multipart_max_file_bytes` what one multipart request carries per file, `next_tier` what more accounts would unlock.
|
|
655
|
+
|
|
656
|
+
```ruby
|
|
657
|
+
limits = client.media.limits(workspace_id: workspace.id)
|
|
658
|
+
limits.max_file_bytes # => 419430400
|
|
659
|
+
limits.multipart_max_file_bytes # => 104857600
|
|
660
|
+
limits.next_tier&.up_to_accounts # => 15
|
|
661
|
+
limits.allowed_types
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
Direct uploads to the media library, in three steps: presign a slot, PUT the bytes to the returned URL with the returned headers (no API key), then complete. `upload_direct` does all three and returns the media item; a rejected PUT raises before `complete` is called. `size` may go up to `max_file_bytes`, and `presign` also takes `folder_id`, `origin`, `brand_role`, `brand_voice_id` and `person_consent`.
|
|
665
|
+
|
|
666
|
+
```ruby
|
|
667
|
+
item = client.media.upload_direct(
|
|
668
|
+
workspace_id: workspace.id,
|
|
669
|
+
filename: 'logo.png',
|
|
670
|
+
mime_type: 'image/png',
|
|
671
|
+
data: File.binread('logo.png')
|
|
672
|
+
)
|
|
673
|
+
item.id
|
|
674
|
+
item.preview_url
|
|
675
|
+
|
|
676
|
+
# Or step by step
|
|
677
|
+
upload = client.media.presign(workspace_id: workspace.id, filename: 'logo.png', mime_type: 'image/png', size: 4096,
|
|
678
|
+
brand_role: 'logo')
|
|
679
|
+
upload.upload_url # PUT the bytes here with upload.headers
|
|
680
|
+
upload.max_file_bytes
|
|
681
|
+
client.media.complete(upload.upload_id)
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
## WhatsApp Business
|
|
685
|
+
|
|
686
|
+
A WhatsApp Business number you already own. The platform owns the templates,
|
|
687
|
+
flows, profile and commerce settings, so every call is live; all of it answers
|
|
688
|
+
503 until WhatsApp is set up. Needs the `accounts` scope, except the sandbox,
|
|
689
|
+
which sends a template and needs `publish`.
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
profile = client.whatsapp.profile(account_id)
|
|
693
|
+
profile.quality_rating # as the platform reports it
|
|
694
|
+
|
|
695
|
+
# Filing a template returns the review status the platform gave it.
|
|
696
|
+
template = client.whatsapp.create_template(
|
|
697
|
+
account_id,
|
|
698
|
+
name: 'order_shipped',
|
|
699
|
+
language: 'en_US',
|
|
700
|
+
category: 'UTILITY',
|
|
701
|
+
components: [{ type: 'BODY', text: 'Your order is on its way.' }]
|
|
702
|
+
)
|
|
703
|
+
template.status # PENDING until the platform approves it
|
|
704
|
+
|
|
705
|
+
# A flow is created as a draft, its screens uploaded, then published.
|
|
706
|
+
flow = client.whatsapp.create_flow(account_id, name: 'Book a fitting', categories: ['LEAD_GENERATION'])
|
|
707
|
+
client.whatsapp.upload_flow_json(account_id, flow.id, { 'version' => '7.0', 'screens' => [] })
|
|
708
|
+
client.whatsapp.publish_flow(account_id, flow.id)
|
|
709
|
+
client.whatsapp.flow_responses(account_id)
|
|
710
|
+
|
|
711
|
+
# Groups are invite-only: there is no endpoint that adds a participant.
|
|
712
|
+
group = client.whatsapp.create_group(account_id, subject: 'Launch crew')
|
|
713
|
+
client.whatsapp.group_invite_link(account_id, group.id)
|
|
714
|
+
## Analytics
|
|
715
|
+
|
|
716
|
+
```ruby
|
|
717
|
+
# How long a post keeps earning, from the repeated readings of each post
|
|
718
|
+
decay = client.analytics.decay(days: 30)
|
|
719
|
+
puts decay.half_life_bucket # => "1h_3h"
|
|
720
|
+
|
|
721
|
+
# Whether posting more earned more
|
|
722
|
+
cadence = client.analytics.frequency(days: 90)
|
|
723
|
+
puts cadence.best&.label # => "3-5 a week"
|
|
724
|
+
|
|
725
|
+
# Every reading held for one post, with what moved between them
|
|
726
|
+
timeline = client.analytics.timeline(post.id)
|
|
727
|
+
|
|
728
|
+
# Mirror the metrics into your own store, without refetching everything
|
|
729
|
+
cursor = nil
|
|
730
|
+
loop do
|
|
731
|
+
page = client.analytics.changes(since: cursor)
|
|
732
|
+
save(page.changes)
|
|
733
|
+
break unless page.has_more && page.cursor
|
|
734
|
+
|
|
735
|
+
cursor = page.cursor
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Refresh one post now instead of waiting for the next collection run
|
|
739
|
+
client.analytics.collect_post(post.id)
|
|
740
|
+
|
|
741
|
+
# Posts on the account that never went out through FoPost
|
|
742
|
+
client.analytics.native_posts(accounts.first.id).each do |native|
|
|
743
|
+
puts "#{native.permalink} #{native.metrics.engagements}"
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
A post is addressed by its FoPost id or by its permalink, so a post made by
|
|
748
|
+
hand on the network works the same way:
|
|
749
|
+
|
|
750
|
+
```ruby
|
|
751
|
+
client.analytics.timeline('https://x.com/acme/status/1')
|
|
752
|
+
```
|
|
753
|
+
|
|
208
754
|
## Errors
|
|
209
755
|
|
|
210
756
|
Every non-2xx response raises. All of them are rescuable as `Fopost::Error`.
|
|
@@ -216,6 +762,7 @@ Every non-2xx response raises. All of them are rescuable as `Fopost::Error`.
|
|
|
216
762
|
| 402 | `Fopost::PaymentRequiredError` |
|
|
217
763
|
| 403 | `Fopost::PermissionDeniedError` |
|
|
218
764
|
| 404 | `Fopost::NotFoundError` |
|
|
765
|
+
| 413 | `Fopost::FileTooLargeError` |
|
|
219
766
|
| 429 | `Fopost::RateLimitError` |
|
|
220
767
|
| other | `Fopost::Error` |
|
|
221
768
|
|
|
@@ -229,7 +776,7 @@ rescue Fopost::Error => e
|
|
|
229
776
|
end
|
|
230
777
|
```
|
|
231
778
|
|
|
232
|
-
`message` is what the API said; `to_s` adds the status and code, so an uncaught error still reports both. `e.body` holds the decoded response, and `Fopost::ValidationError#errors` carries per-field messages when the API sends them.
|
|
779
|
+
`message` is what the API said; `to_s` adds the status and code, so an uncaught error still reports both. `e.body` holds the decoded response, and `Fopost::ValidationError#errors` carries per-field messages when the API sends them. `Fopost::FileTooLargeError` reads `max_file_bytes`, `file_bytes` and `upgrade_url` off the body.
|
|
233
780
|
|
|
234
781
|
## Configuration
|
|
235
782
|
|
|
@@ -288,6 +835,22 @@ post['some_new_field'] # by either spelling
|
|
|
288
835
|
|
|
289
836
|
That means a field added server-side never breaks an older client.
|
|
290
837
|
|
|
838
|
+
## Chatbots and the inbox
|
|
839
|
+
|
|
840
|
+
The [chat adapter](https://fopost.com/docs/sdks/chat-adapter) turns the FoPost inbox into one send/receive channel for a chatbot
|
|
841
|
+
framework. It ships in the TypeScript and Python SDKs. There is no dedicated adapter here and no
|
|
842
|
+
API change behind it, so the same loop is three pieces with this client:
|
|
843
|
+
|
|
844
|
+
1. **Verify** the `inbox.message_received` webhook. The payload is ids only, on purpose, so
|
|
845
|
+
nothing a customer wrote sits in your logs. The [signing scheme](https://fopost.com/docs/webhooks/verification)
|
|
846
|
+
is HMAC-SHA256 over `{timestamp}.{body}`, refused past a five minute tolerance.
|
|
847
|
+
2. **Read** the item back with `client.inbox.list(workspace_id:, type: 'dm', account_id:)`, filtered to the payload's
|
|
848
|
+
`accountId` and matched on its `itemId`.
|
|
849
|
+
3. **Answer** with `client.inbox.reply(item.id, text: text)`, or open a thread with
|
|
850
|
+
`client.inbox.start_conversation(account_id:, handle:, text:)`.
|
|
851
|
+
|
|
852
|
+
Reading needs the `inbox` scope; answering needs `publish` as well.
|
|
853
|
+
|
|
291
854
|
## Development
|
|
292
855
|
|
|
293
856
|
```bash
|
|
@@ -301,3 +864,30 @@ Tests run against a stubbed transport, so nothing touches the network.
|
|
|
301
864
|
## License
|
|
302
865
|
|
|
303
866
|
MIT. See [LICENSE](LICENSE).
|
|
867
|
+
|
|
868
|
+
### Google Ads
|
|
869
|
+
|
|
870
|
+
Campaigns, ad groups, ads, audiences and insights are on `client.ads` and dispatch by
|
|
871
|
+
connection. What only Google has is under `client.ads.google`:
|
|
872
|
+
|
|
873
|
+
```ruby
|
|
874
|
+
keywords = client.ads.google.keywords(
|
|
875
|
+
connection_id: 'c4d5e6f7-…',
|
|
876
|
+
customer_id: '1234567890'
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
client.ads.google.create_keyword(
|
|
880
|
+
workspace_id: '7d2b8c11-…',
|
|
881
|
+
connection_id: 'c4d5e6f7-…',
|
|
882
|
+
customer_id: '1234567890',
|
|
883
|
+
ad_group_id: '1234567890~adGroup~77',
|
|
884
|
+
text: 'running shoes',
|
|
885
|
+
match_type: 'EXACT'
|
|
886
|
+
)
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
Also `keyword_ideas`, `keyword_metrics`, `search_terms`, `bid_strategies`, `ad_schedule`
|
|
890
|
+
and `set_ad_schedule`, the negative keyword lists, `assets` and `asset_groups`,
|
|
891
|
+
`local_services_leads`, the conversion methods, and `query` for a raw read-only GAQL
|
|
892
|
+
SELECT. Changes need the `publish` scope as well as `ads`; `customer_id` has to name an
|
|
893
|
+
account the connection's grant reaches.
|