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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b3fed727cadc18b6ea190937598241ea06e5ac3451f5a01e47e993f641f5332
4
- data.tar.gz: 6adeeba6ddc69911b79c4b620ddc418e0dd372d6e76e3a417bd285ae54b44e9d
3
+ metadata.gz: 26f8bcb600108496ff4cf072d383bf2dbb1082c573b14d2f0e7b8986284c1b1d
4
+ data.tar.gz: e42dc4799da58ada826fc37d34cab90c96d91b92afed5117f89dce73f01cdf2c
5
5
  SHA512:
6
- metadata.gz: ad4c11a0f91aa25cd0dfab676f0a28b5973d139a82137c0d753de697a453d710bd9f59f6c212596230fe727e37d20e117635d7b23d06f2f6aeeefce3d66b5ffa
7
- data.tar.gz: 1b53388871737de150a7d71387472915b3e1bfcc7c8e245f45e5c3d87ea4ef2ee9d8047855876d33e1028c0b24272accfe663ef482e83bbdc4e60a72fe9268af
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 on LinkedIn, an image carousel elsewhere).
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]["id"]
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 is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error, and it needs a Threads connection with the reply permission (a 401 `reauth_required` means reconnect Threads). 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"`).
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
- reply = client.inbox.reply(message["conversation_id"], text: "Thanks! DM sent.", include_next: true)
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.failed` events:
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
@@ -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, :inbox, :webhooks
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, for Instagram/Facebook DMs
30
- # within the 24-hour messaging window only, or a comment/mention that
31
- # has not been replied to and is not hidden; replies typed in the native
32
- # apps count as answers, and read state is ignored, so use `next` for a
33
- # work queue), limit (1-100), and cursor (an opaque cursor from a
34
- # previous response's pagination["next_cursor"]).
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). Threads inbox is currently rolling
137
- # out; until Meta approves the permissions it is disabled on production
138
- # and Threads calls return a clear error.
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 the oldest (by default) item
169
- # that still needs a reply, together with its conversation so far and
170
- # the post it belongs to, so a reply can be drafted from one call. An
171
- # item needs an answer when it is the customer's latest DM with no
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". platform and type ("dm",
186
- # "comment", "mention") narrow the queue.
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 when nothing is
190
- # waiting. "message" is the unanswered incoming item itself (the
191
- # customer's latest DM, or the specific comment): its "id" is what
192
- # `hide` and `delete_message` take, its "conversation_id" is what
193
- # `reply` takes. "messages" is the conversation so far, oldest first
194
- # (the most recent 50 messages for long DM threads). "remaining" is the
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. For files
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. The signing `secret` is
28
- # only returned once, in this response - store it.
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",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniSocials
4
- VERSION = "0.6.0"
4
+ VERSION = "0.9.0"
5
5
  end
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.6.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-09-17 00:00:00.000000000 Z
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