omnisocials 0.6.0 → 0.9.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/README.md +61 -6
- data/lib/omnisocials/client.rb +3 -1
- data/lib/omnisocials/resources/inbox.rb +57 -24
- data/lib/omnisocials/resources/media.rb +11 -7
- data/lib/omnisocials/resources/pinterest.rb +61 -0
- data/lib/omnisocials/resources/posts.rb +29 -0
- data/lib/omnisocials/resources/webhooks.rb +4 -3
- data/lib/omnisocials/version.rb +1 -1
- data/lib/omnisocials.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26f8bcb600108496ff4cf072d383bf2dbb1082c573b14d2f0e7b8986284c1b1d
|
|
4
|
+
data.tar.gz: e42dc4799da58ada826fc37d34cab90c96d91b92afed5117f89dce73f01cdf2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92398e94cbc636eb8dfede06d8cb2dfc0d5a7239c39ef72e417b6f8de5e982ed8538c4f5d7f61c6bdb70cfc2e52a76a556d58d27bd6c5cf161c784d6a30b824f
|
|
7
|
+
data.tar.gz: 1a5e379ad7bafe61ba70912d724531670ac7245e44c5d45e4e4968bfb458f0b030ab9a03ec64001fa3ddd6b50021c4854df280af220b1c6413ce17b30ae30d3d
|
data/README.md
CHANGED
|
@@ -208,6 +208,20 @@ client.posts.reject("123", comment: "Wrong CTA link, please fix.") # reject and
|
|
|
208
208
|
|
|
209
209
|
Only works on a post with `approval_status: "pending"` (`status: "in_approval"`). Both act on behalf of the user who owns the API key, who must be a listed approver for the workflow's CURRENT step — steps approve in order, so being an approver on a later step is not enough yet (raises a 403 `OmniSocials::PermissionDeniedError`). Approving the last step finalizes the post (`scheduled` or `posting`); rejecting stops the whole workflow immediately, not just the current step.
|
|
210
210
|
|
|
211
|
+
### Read the approval review
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
review = client.posts.get_approval("123")["data"]
|
|
215
|
+
if review["status"] == "rejected" && review["rejection"]
|
|
216
|
+
puts "Rejected by #{review["rejection"]["by"]["name"]}: #{review["rejection"]["reason"]}"
|
|
217
|
+
end
|
|
218
|
+
review["steps"].each do |step|
|
|
219
|
+
puts "#{step["order"]} #{step["name"]} #{step["status"]}"
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`get_approval` returns the review of a post that went through an approval workflow: `status` (`none`, `pending`, `approved`, `rejected`), the `workflow`, who requested it and when, `current_step` (the step the post waits on, `nil` when the review ended), every step with its approvers and their decisions, the `rejection` (`by`, `reason`, `at`, `step`; `nil` when nobody rejected) and the `comments` thread, oldest first. A post without an approval workflow returns `status: "none"` with empty `steps` and `comments`. Read-only; needs the `posts:read` scope.
|
|
224
|
+
|
|
211
225
|
## Media
|
|
212
226
|
|
|
213
227
|
### Upload from a URL (recommended, up to 1GB)
|
|
@@ -231,7 +245,7 @@ media = client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")
|
|
|
231
245
|
|
|
232
246
|
`file` accepts a file path (String or Pathname), an IO (`File.open("...", "rb")`, StringIO), or raw bytes as a binary-encoded String (e.g. from `File.binread`).
|
|
233
247
|
|
|
234
|
-
Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (a native swipeable document
|
|
248
|
+
Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (on LinkedIn a native swipeable document made from the original PDF file, which is kept so text and links stay intact; an image carousel elsewhere). To keep the PDF as ONE library item instead of one item per page, pass `pdf_mode` = `"document"`: the response then has a single `data` item of type `document` (its page images in `pdf.pages`) and `media_ids` holds that one id, which expands into every page at post time.
|
|
235
249
|
|
|
236
250
|
Every upload response also includes a `compatibility` block listing any connected platforms that would reject the file.
|
|
237
251
|
|
|
@@ -373,6 +387,32 @@ client.posts.create(
|
|
|
373
387
|
|
|
374
388
|
The Threads response is `{ "locations" => [...] }` (each with nullable `name`, `address`, `city`, `country`, `latitude`, `longitude`) or `{ "error" => { "code", "message" } }` with `code` one of `not_available`, `threads_not_connected`, `threads_reauth_required` (reconnect Threads), or `platform_error`. Threads location tagging is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error.
|
|
375
389
|
|
|
390
|
+
## Pinterest product tags
|
|
391
|
+
|
|
392
|
+
Tag products on a Pin so people can shop the items in the image. `client.pinterest.list_products` returns the product Pins of the connected Pinterest account; pass their `pin_id` values (max 24) as `"product_tags"` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the outcome is on the post as `pinterest["product_tags_result"]` (`requested`, `tagged`, `skipped`, `error`).
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
result = client.pinterest.list_products
|
|
396
|
+
|
|
397
|
+
if result["error"]
|
|
398
|
+
# HTTP 200 without "products": pinterest_not_connected,
|
|
399
|
+
# pinterest_catalog_access_required or platform_error
|
|
400
|
+
warn "#{result["error"]["code"]}: #{result["error"]["message"]}"
|
|
401
|
+
else
|
|
402
|
+
product_tags = result["products"].first(3).map { |product| product["pin_id"] }
|
|
403
|
+
|
|
404
|
+
client.posts.create(
|
|
405
|
+
content: "Our summer picks",
|
|
406
|
+
channels: ["pinterest"],
|
|
407
|
+
media_urls: ["https://example.com/summer-look.jpg"],
|
|
408
|
+
scheduled_at: "2026-08-01T09:00:00Z",
|
|
409
|
+
pinterest: { "board_id" => "1234567890", "title" => "Summer picks", "product_tags" => product_tags }
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Without `source:` the list reads the Pinterest catalog (with `price`, `currency`, `availability` and `item_id`) when the connection has catalog access, else the account's own Pins. Catalog access is given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog. `source: "pins"` scans up to 250 Pins per call, so `"products"` can be empty while `"bookmark"` is set; call again with `bookmark: result["bookmark"]`. To check one Pin id or Pin link before you post, call `client.pinterest.validate_product("813744226420795884")`. On `posts.update` the `pinterest` hash replaces the stored one, so leave `"product_tags"` out to remove the tags.
|
|
415
|
+
|
|
376
416
|
## Inbox
|
|
377
417
|
|
|
378
418
|
Read and reply to social inbox conversations (DMs, comments, mentions) across connected platforms. Requires an API key with the opt-in `inbox:read` / `inbox:write` scopes. The list endpoints are cursor-paginated (unlike the offset pagination used elsewhere): page while `pagination["has_more"]` is true by passing the previous response's `pagination["next_cursor"]` as `cursor`.
|
|
@@ -383,7 +423,7 @@ conversations["data"].each do |conversation|
|
|
|
383
423
|
puts "#{conversation["platform"]}: #{conversation["preview"]}"
|
|
384
424
|
end
|
|
385
425
|
|
|
386
|
-
conversation_id = conversations["data"][0]["
|
|
426
|
+
conversation_id = conversations["data"][0]["conversation_id"]
|
|
387
427
|
messages = client.inbox.get_messages(conversation_id)
|
|
388
428
|
messages["data"].each do |message|
|
|
389
429
|
puts "#{message["direction"]}: #{message["text"]}" # direction is "incoming" or "outgoing"
|
|
@@ -401,9 +441,9 @@ client.inbox.hide(message_id, hide: false) # unhide
|
|
|
401
441
|
client.inbox.delete_message(message_id)
|
|
402
442
|
```
|
|
403
443
|
|
|
404
|
-
`platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, `"youtube"`, `"x"`, or `"threads"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. Threads conversations are comments (replies people leave on your Threads posts) and mentions; there are no Threads DMs. Comments can be hidden on Facebook, Instagram, TikTok, YouTube, and Threads (Threads: incoming top-level replies only), and a hidden message keeps its place in the conversation with its `hidden` flag set; `hidden` is `true`/`false` on comments and `nil` on DMs. A comment/mention's `post` carries `url` (public link when the platform provides one) and `media_type` (the platform's own label) next to `id`, `caption`, and `thumbnail`. Threads inbox
|
|
444
|
+
`platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, `"youtube"`, `"x"`, or `"threads"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. Threads conversations are comments (replies people leave on your Threads posts) and mentions; there are no Threads DMs. Comments can be hidden on Facebook, Instagram, TikTok, YouTube, and Threads (Threads: incoming top-level replies only), and a hidden message keeps its place in the conversation with its `hidden` flag set; `hidden` is `true`/`false` on comments and `nil` on DMs. A comment/mention's `post` carries `url` (public link when the platform provides one) and `media_type` (the platform's own label) next to `id`, `caption`, and `thumbnail`. The Threads inbox needs a Threads connection with the reply permission (a 401 `reauth_required` means reconnect Threads; an account connected before 2026-09-14 needs this once). TikTok and YouTube replies are comments only; TikTok replies are capped at 150 characters. Conversation ids are URL-encoded for you, so pass them exactly as returned - LinkedIn ids contain `":"` and `"()"` (e.g. `"linkedin_comment_urn:li:activity:123"`).
|
|
405
445
|
|
|
406
|
-
`next` hands out the next conversation that still needs a reply (the customer's latest DM with no reply after it, or an unreplied comment/mention that is not hidden), with the whole thread and the post it belongs to, so a reply can be drafted from one call. Replies typed in the native apps count as answers. Only unread items are served by default, so `mark_read` is the durable way to skip one; `exclude` skips conversation ids for the current session only. Pass `include_next: true` to `reply` to get the following item in the same response. `list_conversations(unanswered: true)` gives the same set as a plain list.
|
|
446
|
+
`next` hands out the next conversation that still needs a reply (the customer's latest DM with no reply after it, or an unreplied comment/mention that is not hidden), with the whole thread and the post it belongs to, so a reply can be drafted from one call. DMs that can still be answered come first, then Instagram/Facebook DMs whose 24-hour window has closed (`reply_window["open"]` is `false`: answer those from the native app or mark them read), then comments and mentions, oldest first. Replies typed in the native apps count as answers. Only unread items are served by default, so `mark_read` is the durable way to skip one; `exclude` skips conversation ids for the current session only. Always pass `message_id: message["id"]` to `reply` on comment threads: every comment on a post shares one conversation, and without it the reply goes under the newest comment on the post. Pass `include_next: true` to `reply` to get the following item in the same response. `list_conversations(unanswered: true)` gives the same set as a plain list.
|
|
407
447
|
|
|
408
448
|
```ruby
|
|
409
449
|
item = client.inbox.next(platform: "instagram")
|
|
@@ -411,7 +451,20 @@ while item["data"]
|
|
|
411
451
|
message = item["data"]["message"]
|
|
412
452
|
puts "#{item["remaining"]} left. #{message["sender"]["username"]}: #{message["text"]}"
|
|
413
453
|
|
|
414
|
-
|
|
454
|
+
unless item["data"]["reply_window"]["open"]
|
|
455
|
+
# An Instagram/Facebook DM past Meta's 24-hour window: reply would raise
|
|
456
|
+
# 422 outside_messaging_window. Answer it in the app, or skip it.
|
|
457
|
+
client.inbox.mark_read(message["conversation_id"])
|
|
458
|
+
item = client.inbox.next(platform: "instagram")
|
|
459
|
+
next
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
reply = client.inbox.reply(
|
|
463
|
+
message["conversation_id"],
|
|
464
|
+
text: "Thanks! DM sent.",
|
|
465
|
+
message_id: message["id"], # the comment being answered, not the newest one
|
|
466
|
+
include_next: true
|
|
467
|
+
)
|
|
415
468
|
item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
|
|
416
469
|
end
|
|
417
470
|
```
|
|
@@ -437,7 +490,7 @@ end
|
|
|
437
490
|
|
|
438
491
|
## Webhooks
|
|
439
492
|
|
|
440
|
-
Subscribe to `post.scheduled`, `post.published`, and `post.
|
|
493
|
+
Subscribe to `post.scheduled`, `post.published`, `post.failed`, `post.approved`, and `post.rejected` events. `post.approved` fires when the last step of a post's approval workflow is approved, `post.rejected` when an approver rejects the post (it will not publish). These two carry `data["approval"]` with `status`, `decided_by` (the approver's user id) and `reason` (`nil` on `post.approved`), and an empty `data["targets"]`.
|
|
441
494
|
|
|
442
495
|
```ruby
|
|
443
496
|
webhook = client.webhooks.create(
|
|
@@ -480,6 +533,8 @@ class OmnisocialsWebhooksController < ApplicationController
|
|
|
480
533
|
event["data"]["targets"].each do |target|
|
|
481
534
|
Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
|
|
482
535
|
end
|
|
536
|
+
elsif event["type"] == "post.rejected"
|
|
537
|
+
Rails.logger.info "Rejected: #{event["data"]["post_id"]} #{event["data"]["approval"]["reason"]}"
|
|
483
538
|
end
|
|
484
539
|
|
|
485
540
|
head :ok
|
data/lib/omnisocials/client.rb
CHANGED
|
@@ -60,7 +60,8 @@ module OmniSocials
|
|
|
60
60
|
|
|
61
61
|
attr_reader :api_key, :base_url, :timeout, :max_retries,
|
|
62
62
|
:posts, :media, :folders, :hashtag_sets, :approval_workflows,
|
|
63
|
-
:accounts, :analytics, :audio, :locations, :
|
|
63
|
+
:accounts, :analytics, :audio, :locations, :pinterest, :inbox,
|
|
64
|
+
:webhooks
|
|
64
65
|
|
|
65
66
|
# api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
|
|
66
67
|
# OMNISOCIALS_API_KEY environment variable. Raises
|
|
@@ -94,6 +95,7 @@ module OmniSocials
|
|
|
94
95
|
@analytics = Resources::Analytics.new(self)
|
|
95
96
|
@audio = Resources::Audio.new(self)
|
|
96
97
|
@locations = Resources::Locations.new(self)
|
|
98
|
+
@pinterest = Resources::Pinterest.new(self)
|
|
97
99
|
@inbox = Resources::Inbox.new(self)
|
|
98
100
|
@webhooks = Resources::Webhooks.new(self)
|
|
99
101
|
end
|
|
@@ -26,12 +26,14 @@ module OmniSocials
|
|
|
26
26
|
# "linkedin", "tiktok", "youtube", "x", "threads"), type ("dm", "comment",
|
|
27
27
|
# "mention"), unread (only conversations with unread messages),
|
|
28
28
|
# unanswered (only conversations that still need an answer: the
|
|
29
|
-
# customer's latest DM has no reply after it,
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
29
|
+
# customer's latest DM has no reply after it, Instagram/Facebook DMs
|
|
30
|
+
# past Meta's 24-hour messaging window included (they cannot be answered
|
|
31
|
+
# through the API, but the customer is still waiting), or a
|
|
32
|
+
# comment/mention that has not been replied to and is not hidden;
|
|
33
|
+
# replies typed in the native apps count as answers, and read state is
|
|
34
|
+
# ignored, so use `next` for a work queue), limit (1-100), and cursor
|
|
35
|
+
# (an opaque cursor from a previous response's
|
|
36
|
+
# pagination["next_cursor"]).
|
|
35
37
|
#
|
|
36
38
|
# Threads conversations are type "comment" (replies people leave on the
|
|
37
39
|
# user's Threads posts; conversation ids look like
|
|
@@ -91,17 +93,33 @@ module OmniSocials
|
|
|
91
93
|
# in the dashboard to resume; DMs that arrived while suspended are
|
|
92
94
|
# not recovered).
|
|
93
95
|
#
|
|
96
|
+
# On comment and mention threads, pass message_id: (the "id" of the
|
|
97
|
+
# comment being answered: message["id"] from `next`, or a message "id"
|
|
98
|
+
# from `get_messages`). Every comment on a post shares one
|
|
99
|
+
# conversation, so without it the reply is posted under the newest
|
|
100
|
+
# comment on the post, which may be a different person than the one you
|
|
101
|
+
# drafted for. Ignored for DMs. 404 "not_found" when it is not an
|
|
102
|
+
# incoming message of this conversation.
|
|
103
|
+
#
|
|
104
|
+
# Instagram and Facebook DMs can only be answered within 24 hours of
|
|
105
|
+
# the customer's last message (Meta policy). That is checked before the
|
|
106
|
+
# send: a closed window raises 422 "outside_messaging_window" and
|
|
107
|
+
# nothing is sent (`next` reports the same in "reply_window"). Answer
|
|
108
|
+
# such a DM from the Instagram or Facebook app (mirrored into the
|
|
109
|
+
# inbox) or mark the conversation read; do not retry.
|
|
110
|
+
#
|
|
94
111
|
# Pass include_next: true to also get "next" (the next conversation
|
|
95
112
|
# that needs an answer, the same object `next` returns under "data",
|
|
96
113
|
# using its default queue order and filters; nil when nothing is
|
|
97
114
|
# waiting) and "remaining" in the response. Saves the extra call when
|
|
98
115
|
# working through the inbox.
|
|
99
|
-
def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, include_next: nil)
|
|
116
|
+
def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, message_id: nil, include_next: nil)
|
|
100
117
|
body = Internal.drop_nil(
|
|
101
118
|
{
|
|
102
119
|
"text" => text,
|
|
103
120
|
"attachment_url" => attachment_url,
|
|
104
121
|
"attachment_type" => attachment_type,
|
|
122
|
+
"message_id" => message_id,
|
|
105
123
|
"include_next" => include_next
|
|
106
124
|
}
|
|
107
125
|
)
|
|
@@ -133,9 +151,15 @@ module OmniSocials
|
|
|
133
151
|
# "not_found" (message not in this workspace) or
|
|
134
152
|
# "account_not_connected", 429 "quota_exceeded" (YouTube's daily API
|
|
135
153
|
# quota is used up; retry after midnight Pacific), 502 "platform_error"
|
|
136
|
-
# (the platform rejected the call)
|
|
137
|
-
#
|
|
138
|
-
#
|
|
154
|
+
# (the platform rejected the call), 502 "hide_not_applied" (Instagram
|
|
155
|
+
# accepted the call but, read back, still reports the comment in its
|
|
156
|
+
# old state; this happens with comments Instagram shows under "Comments
|
|
157
|
+
# from Facebook" on a reel that is also shared to Facebook, which live
|
|
158
|
+
# on Facebook where Instagram's hide does not reach them; the inbox row
|
|
159
|
+
# is left unchanged, so hide it in the Instagram or Facebook app and do
|
|
160
|
+
# not retry). A Threads account connected before 2026-09-14 needs a
|
|
161
|
+
# one-time reconnect; until then a Threads hide answers 401
|
|
162
|
+
# "reauth_required".
|
|
139
163
|
def hide(message_id, hide: true)
|
|
140
164
|
@client.request(
|
|
141
165
|
"POST", "/inbox/messages/#{encode_id(message_id)}/hide",
|
|
@@ -165,13 +189,17 @@ module OmniSocials
|
|
|
165
189
|
end
|
|
166
190
|
|
|
167
191
|
# GET /inbox/next - the next conversation that needs an answer: a work
|
|
168
|
-
# queue for answering the inbox. Returns
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
# reply after it (Instagram/Facebook DMs within the 24-hour messaging
|
|
173
|
-
# window only, since Meta refuses replies outside it), or a
|
|
192
|
+
# queue for answering the inbox. Returns one item that still needs a
|
|
193
|
+
# reply, together with its conversation so far and the post it belongs
|
|
194
|
+
# to, so a reply can be drafted from one call. An item needs an answer
|
|
195
|
+
# when it is the customer's latest DM with no reply after it, or a
|
|
174
196
|
# comment/mention that has not been replied to and is not hidden.
|
|
197
|
+
# Order: DMs that can still be answered come first (Instagram/Facebook
|
|
198
|
+
# DMs inside Meta's 24-hour window, the one whose window closes soonest
|
|
199
|
+
# first, and X DMs), then Instagram/Facebook DMs whose window has
|
|
200
|
+
# closed (served with "reply_window"["open"] false: answer them from
|
|
201
|
+
# the native app or mark them read), then comments and mentions, oldest
|
|
202
|
+
# first by default.
|
|
175
203
|
# Replies typed in the native apps count as answers (they are mirrored
|
|
176
204
|
# into the inbox), so a thread a colleague answered on their phone is
|
|
177
205
|
# not served again. Instagram mentions are skipped (no reply path).
|
|
@@ -182,16 +210,21 @@ module OmniSocials
|
|
|
182
210
|
# include read-but-unanswered items. exclude is a session-local skip:
|
|
183
211
|
# conversation ids (an Array, or a comma-separated String) to leave out
|
|
184
212
|
# of this call, up to 100. order is "oldest" (default: the item that
|
|
185
|
-
# has waited longest first) or "newest"
|
|
186
|
-
# "comment", "mention") narrow the
|
|
213
|
+
# has waited longest first) or "newest"; it reverses the order within
|
|
214
|
+
# each group. platform and type ("dm", "comment", "mention") narrow the
|
|
215
|
+
# queue.
|
|
187
216
|
#
|
|
188
217
|
# Returns { "data" => ..., "remaining" => Integer }. "data" is
|
|
189
|
-
# { "conversation", "message", "messages" }, or nil
|
|
190
|
-
# waiting. "message" is the unanswered incoming item
|
|
191
|
-
# customer's latest DM, or the specific comment): its "id"
|
|
192
|
-
# `hide` and `delete_message` take
|
|
193
|
-
# `reply`
|
|
194
|
-
#
|
|
218
|
+
# { "conversation", "message", "messages", "reply_window" }, or nil
|
|
219
|
+
# when nothing is waiting. "message" is the unanswered incoming item
|
|
220
|
+
# itself (the customer's latest DM, or the specific comment): its "id"
|
|
221
|
+
# is what `hide` and `delete_message` take and the message_id: to pass
|
|
222
|
+
# to `reply` on comment threads, its "conversation_id" is what `reply`
|
|
223
|
+
# takes. "messages" is the conversation so far, oldest first (the most
|
|
224
|
+
# recent 50 messages for long DM threads). "reply_window" is
|
|
225
|
+
# { "open" => Boolean, "closes_at" => String|nil }: "open" is false
|
|
226
|
+
# only for an Instagram/Facebook DM past its 24-hour window, which
|
|
227
|
+
# `reply` refuses with 422 "outside_messaging_window". "remaining" is the
|
|
195
228
|
# number of unanswered items still waiting after this one (capped at
|
|
196
229
|
# 500), 0 when "data" is nil. To chain the queue, pass
|
|
197
230
|
# include_next: true to `reply` and it returns the next item in the
|
|
@@ -32,13 +32,15 @@ module OmniSocials
|
|
|
32
32
|
# binary-encoded String, e.g. from File.binread).
|
|
33
33
|
#
|
|
34
34
|
# A PDF is split into image slides and the response carries `slides`
|
|
35
|
-
# plus a `media_ids` array instead of a single `data` item
|
|
35
|
+
# plus a `media_ids` array instead of a single `data` item; pass
|
|
36
|
+
# pdf_mode: "document" to keep it as ONE item of type "document" whose
|
|
37
|
+
# single id in media_ids expands into every page. For files
|
|
36
38
|
# over 100MB use upload_from_url (up to 1GB) or the create_upload_url
|
|
37
39
|
# presigned flow.
|
|
38
|
-
def upload(file:, filename: nil, name: nil, folder: nil, folder_id: nil)
|
|
40
|
+
def upload(file:, filename: nil, name: nil, folder: nil, folder_id: nil, pdf_mode: nil)
|
|
39
41
|
upload_name, data = Internal.coerce_file(file, filename)
|
|
40
42
|
fields = Internal.drop_nil(
|
|
41
|
-
{ "name" => name, "folder" => folder, "folder_id" => folder_id }
|
|
43
|
+
{ "name" => name, "folder" => folder, "folder_id" => folder_id, "pdf_mode" => pdf_mode }
|
|
42
44
|
)
|
|
43
45
|
@client.request(
|
|
44
46
|
"POST", "/media/upload",
|
|
@@ -50,14 +52,15 @@ module OmniSocials
|
|
|
50
52
|
#
|
|
51
53
|
# Files over 100MB are streamed in the background: the response has
|
|
52
54
|
# data["status"] == "processing"; poll get() until it is "ready".
|
|
53
|
-
def upload_from_url(url:, filename: nil, name: nil, folder: nil, folder_id: nil)
|
|
55
|
+
def upload_from_url(url:, filename: nil, name: nil, folder: nil, folder_id: nil, pdf_mode: nil)
|
|
54
56
|
body = Internal.drop_nil(
|
|
55
57
|
{
|
|
56
58
|
"url" => url,
|
|
57
59
|
"filename" => filename,
|
|
58
60
|
"name" => name,
|
|
59
61
|
"folder" => folder,
|
|
60
|
-
"folder_id" => folder_id
|
|
62
|
+
"folder_id" => folder_id,
|
|
63
|
+
"pdf_mode" => pdf_mode
|
|
61
64
|
}
|
|
62
65
|
)
|
|
63
66
|
@client.request("POST", "/media/upload-from-url", json: body)
|
|
@@ -66,7 +69,7 @@ module OmniSocials
|
|
|
66
69
|
# POST /media/upload-from-base64 - upload base64-encoded data (without
|
|
67
70
|
# a data URI prefix).
|
|
68
71
|
def upload_from_base64(data:, mime_type:, filename: nil, name: nil,
|
|
69
|
-
folder: nil, folder_id: nil)
|
|
72
|
+
folder: nil, folder_id: nil, pdf_mode: nil)
|
|
70
73
|
body = Internal.drop_nil(
|
|
71
74
|
{
|
|
72
75
|
"data" => data,
|
|
@@ -74,7 +77,8 @@ module OmniSocials
|
|
|
74
77
|
"filename" => filename,
|
|
75
78
|
"name" => name,
|
|
76
79
|
"folder" => folder,
|
|
77
|
-
"folder_id" => folder_id
|
|
80
|
+
"folder_id" => folder_id,
|
|
81
|
+
"pdf_mode" => pdf_mode
|
|
78
82
|
}
|
|
79
83
|
)
|
|
80
84
|
@client.request("POST", "/media/upload-from-base64", json: body)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Pinterest resource: product Pins for product tagging (list + validate).
|
|
6
|
+
class Pinterest
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /pinterest/products - list the product Pins of the connected
|
|
12
|
+
# Pinterest account. Pass a result's pin_id in
|
|
13
|
+
# pinterest: { "product_tags" => [...] } on post create/update to tag
|
|
14
|
+
# the product on the Pin (max 24 per Pin). Pinterest only accepts a
|
|
15
|
+
# product Pin that is public, belongs to the same account and links to
|
|
16
|
+
# a website that account claimed; products of other merchants cannot
|
|
17
|
+
# be tagged.
|
|
18
|
+
#
|
|
19
|
+
# source is "catalog" or "pins". "catalog" reads the Pinterest catalog
|
|
20
|
+
# (with price, currency, availability, item_id) and needs catalog
|
|
21
|
+
# access, which is given one time in the OmniSocials composer
|
|
22
|
+
# (Pinterest options, Add products, Connect catalog); product_group_id
|
|
23
|
+
# and page_size (1..100, default 25) apply to this source only. "pins"
|
|
24
|
+
# reads the account's own Pins and works on every connection; one call
|
|
25
|
+
# scans up to 250 Pins, so "products" can be empty while "bookmark" is
|
|
26
|
+
# set (call again with bookmark:). When source is left out the API
|
|
27
|
+
# uses "catalog" when the connection has catalog access, else "pins".
|
|
28
|
+
#
|
|
29
|
+
# Response (not the usual "data" envelope):
|
|
30
|
+
# { "products" => [{ pin_id, title, description, link, image_url,
|
|
31
|
+
# price, currency, availability, item_id }], "bookmark", "source",
|
|
32
|
+
# "catalog_access" } plus "product_groups" and "product_group_id" for
|
|
33
|
+
# the catalog source, or { "error" => { "code", "message" } } without
|
|
34
|
+
# "products" when the list could not be read, both with HTTP 200. code
|
|
35
|
+
# is one of "pinterest_not_connected",
|
|
36
|
+
# "pinterest_catalog_access_required" or "platform_error". A bad source
|
|
37
|
+
# or product_group_id raises a 400 with the standard error envelope.
|
|
38
|
+
def list_products(source: nil, product_group_id: nil, bookmark: nil,
|
|
39
|
+
page_size: nil)
|
|
40
|
+
@client.request(
|
|
41
|
+
"GET", "/pinterest/products",
|
|
42
|
+
query: {
|
|
43
|
+
"source" => source,
|
|
44
|
+
"product_group_id" => product_group_id,
|
|
45
|
+
"bookmark" => bookmark,
|
|
46
|
+
"page_size" => page_size
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /pinterest/products/validate?id= - check whether a Pin can be
|
|
52
|
+
# used in pinterest product_tags before creating the post. id is a Pin
|
|
53
|
+
# id or a Pin link (https://www.pinterest.com/pin/<id>/). Response:
|
|
54
|
+
# { "valid", "pin_id", ... }; "unverified" => true means the check
|
|
55
|
+
# could not run and the publish step is the final check.
|
|
56
|
+
def validate_product(id)
|
|
57
|
+
@client.request("GET", "/pinterest/products/validate", query: { "id" => id })
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -86,6 +86,16 @@ module OmniSocials
|
|
|
86
86
|
# disabled on production and create/update/publish return a 400 (also a
|
|
87
87
|
# 400 validation_error asking you to reconnect Threads when the
|
|
88
88
|
# connection lacks the threads_location_tagging permission).
|
|
89
|
+
#
|
|
90
|
+
# Pinterest posts can tag products: pass
|
|
91
|
+
# pinterest: { "board_id" => "...", "product_tags" => [...] } with up
|
|
92
|
+
# to 24 product Pins of the connected Pinterest account, each as a Pin
|
|
93
|
+
# id String (see pinterest.list_products) or a Pin link. Products of
|
|
94
|
+
# other merchants cannot be tagged. The tags are added right after the
|
|
95
|
+
# Pin is published; a product Pinterest refuses never fails the post,
|
|
96
|
+
# and the Post's "pinterest" block then carries "product_tags_result"
|
|
97
|
+
# (requested, tagged, skipped, error). More than 24 entries or an
|
|
98
|
+
# invalid entry raises a 400 validation_error.
|
|
89
99
|
def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
|
|
90
100
|
media_urls: nil, type: nil, source: nil, link_url: nil,
|
|
91
101
|
link_title: nil, link_description: nil, link_thumbnail_url: nil,
|
|
@@ -154,6 +164,9 @@ module OmniSocials
|
|
|
154
164
|
# and threads thread parts, and to a Threads location tag:
|
|
155
165
|
# threads: { "location_id" => nil } (or "location" => nil) clears it.
|
|
156
166
|
#
|
|
167
|
+
# pinterest replaces the stored Pinterest options wholesale, so leave
|
|
168
|
+
# "product_tags" out (or send []) to remove the product tags.
|
|
169
|
+
#
|
|
157
170
|
# See #create for the 402 "x_credits_insufficient" credit gate that
|
|
158
171
|
# can also refuse an update to a scheduled X link post.
|
|
159
172
|
def update(post_id, content: nil, scheduled_at: nil, channels: nil,
|
|
@@ -238,6 +251,22 @@ module OmniSocials
|
|
|
238
251
|
@client.request("POST", "/posts/#{post_id}/reject", json: body)
|
|
239
252
|
end
|
|
240
253
|
|
|
254
|
+
# GET /posts/{id}/approval - the approval review of a post: every step
|
|
255
|
+
# with its approvers and their decisions, the rejection with its
|
|
256
|
+
# reason, and the comment thread. Use it when approval_status is
|
|
257
|
+
# "rejected" to learn who rejected the post and why, or while it is
|
|
258
|
+
# "pending" to see who the post waits for.
|
|
259
|
+
#
|
|
260
|
+
# `data` carries post_id, status ("none", "pending", "approved",
|
|
261
|
+
# "rejected"), workflow, requested_by, requested_at, current_step,
|
|
262
|
+
# steps, rejection and comments (oldest first). A post without an
|
|
263
|
+
# approval workflow returns status "none" with the object fields nil
|
|
264
|
+
# and empty steps and comments. Read-only; requires the posts:read
|
|
265
|
+
# scope.
|
|
266
|
+
def get_approval(post_id)
|
|
267
|
+
@client.request("GET", "/posts/#{post_id}/approval")
|
|
268
|
+
end
|
|
269
|
+
|
|
241
270
|
private
|
|
242
271
|
|
|
243
272
|
def create_body(content:, channels:, scheduled_at:, media_ids:,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module OmniSocials
|
|
4
4
|
module Resources
|
|
5
5
|
# Webhooks resource: manage event subscriptions (post.scheduled,
|
|
6
|
-
# post.published, post.failed).
|
|
6
|
+
# post.published, post.failed, post.approved, post.rejected).
|
|
7
7
|
#
|
|
8
8
|
# For verifying incoming deliveries, see OmniSocials::Webhooks.verify.
|
|
9
9
|
class Webhooks
|
|
@@ -24,8 +24,9 @@ module OmniSocials
|
|
|
24
24
|
# POST /webhooks - create a webhook subscription.
|
|
25
25
|
#
|
|
26
26
|
# `url` must be HTTPS. `events` is a non-empty subset of
|
|
27
|
-
# post.scheduled, post.published, post.failed.
|
|
28
|
-
# only returned once, in this
|
|
27
|
+
# post.scheduled, post.published, post.failed, post.approved,
|
|
28
|
+
# post.rejected. The signing `secret` is only returned once, in this
|
|
29
|
+
# response - store it.
|
|
29
30
|
def create(url:, events:)
|
|
30
31
|
@client.request(
|
|
31
32
|
"POST", "/webhooks",
|
data/lib/omnisocials/version.rb
CHANGED
data/lib/omnisocials.rb
CHANGED
|
@@ -25,6 +25,7 @@ require_relative "omnisocials/resources/accounts"
|
|
|
25
25
|
require_relative "omnisocials/resources/analytics"
|
|
26
26
|
require_relative "omnisocials/resources/audio"
|
|
27
27
|
require_relative "omnisocials/resources/locations"
|
|
28
|
+
require_relative "omnisocials/resources/pinterest"
|
|
28
29
|
require_relative "omnisocials/resources/inbox"
|
|
29
30
|
require_relative "omnisocials/resources/webhooks"
|
|
30
31
|
require_relative "omnisocials/client"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omnisocials
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OmniSocials
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Schedule and publish social media posts, upload media, and read analytics
|
|
14
14
|
across Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Bluesky, Threads,
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- lib/omnisocials/resources/inbox.rb
|
|
35
35
|
- lib/omnisocials/resources/locations.rb
|
|
36
36
|
- lib/omnisocials/resources/media.rb
|
|
37
|
+
- lib/omnisocials/resources/pinterest.rb
|
|
37
38
|
- lib/omnisocials/resources/posts.rb
|
|
38
39
|
- lib/omnisocials/resources/webhooks.rb
|
|
39
40
|
- lib/omnisocials/version.rb
|