omnisocials 0.5.0 → 0.8.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: 2dff156c383d34ece380b804eb9dc42fd00bd42782218bd99e7f840dfc4c197e
4
- data.tar.gz: 8a7d23f25f08551a42e4b5ad30dc680f2b83045f72bb78c894a9ab13134df8e1
3
+ metadata.gz: 43d0cc71e753d98ddbaa8b1732f140ecfc3d578d02a8e8df81e248c6c9c75131
4
+ data.tar.gz: a66ae890324f9adc7b37b57add4e76ce1be1b110d4b15f751c611bcbfa6adf61
5
5
  SHA512:
6
- metadata.gz: 3f3ca94edd9dbac14a91c91fc17c39b22efda2385dc36fd6fbc02b881c2f86a661e672a55665e97c47d4a8ee278ed6cb0e9bd961f4dcba495b936e6840316b88
7
- data.tar.gz: 9f99feca0fef8165c5d80901cc60f4a3e8f681025e8c786e2e32938582e4194c9a98c8ba60ca54d1166e603bbc3c1f1e751dbdc580e42e9a3032bd650760511a
6
+ metadata.gz: 0b30af0ebca987fe7bebb4710d7648d2c0361e8709cdf716897d4f1f82808dc916159742dcae7c9c0512863e01f68435d642a9341e8254d9cc11511459b496ce
7
+ data.tar.gz: e50bd7f05fe6a2e516ff9ea31bb28307af242c4118ca1a30583008b88591e3b2d9c080fb50447137133f036e21d6fed8721b90bd380cfb670dc3870868ac7710
data/README.md CHANGED
@@ -105,7 +105,7 @@ Note that `linkedin` targets a personal LinkedIn profile and `linkedin_page` tar
105
105
 
106
106
  ### Chained threads (X, Bluesky, Mastodon, Threads)
107
107
 
108
- Pass 2 to 25 `thread_parts` to publish a chained thread instead of a single tweet (each part is at most 280 characters). Bluesky, Mastodon and Threads support the same `thread_parts` shape (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part, and the Threads caption is taken from part 1):
108
+ Pass 2 to 25 `thread_parts` to publish a chained thread instead of a single tweet (each part is at most 280 characters, 25,000 for X Premium/Premium+ accounts). Bluesky, Mastodon and Threads support the same `thread_parts` shape (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part, and the Threads caption is taken from part 1):
109
109
 
110
110
  ```ruby
111
111
  post = client.posts.create(
@@ -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
 
@@ -383,7 +397,7 @@ conversations["data"].each do |conversation|
383
397
  puts "#{conversation["platform"]}: #{conversation["preview"]}"
384
398
  end
385
399
 
386
- conversation_id = conversations["data"][0]["id"]
400
+ conversation_id = conversations["data"][0]["conversation_id"]
387
401
  messages = client.inbox.get_messages(conversation_id)
388
402
  messages["data"].each do |message|
389
403
  puts "#{message["direction"]}: #{message["text"]}" # direction is "incoming" or "outgoing"
@@ -392,13 +406,42 @@ end
392
406
  client.inbox.mark_read(conversation_id)
393
407
  client.inbox.reply(conversation_id, text: "Thanks for reaching out!")
394
408
 
395
- # Threads only: hide or unhide a reply someone left on one of your Threads posts.
409
+ # Hide or unhide a comment someone left on one of your posts (Facebook,
410
+ # Instagram, TikTok, YouTube, Threads). Delete removes it outright (Facebook,
411
+ # Instagram, TikTok; YouTube: hide instead), replies under it included.
396
412
  message_id = messages["data"][0]["id"]
397
413
  client.inbox.hide(message_id) # hide
398
414
  client.inbox.hide(message_id, hide: false) # unhide
415
+ client.inbox.delete_message(message_id)
399
416
  ```
400
417
 
401
- `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. Only incoming top-level Threads replies can be hidden (nested replies cannot), and a hidden message keeps its place in the conversation with its `hidden` flag set. 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"`).
418
+ `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"`).
419
+
420
+ `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.
421
+
422
+ ```ruby
423
+ item = client.inbox.next(platform: "instagram")
424
+ while item["data"]
425
+ message = item["data"]["message"]
426
+ puts "#{item["remaining"]} left. #{message["sender"]["username"]}: #{message["text"]}"
427
+
428
+ unless item["data"]["reply_window"]["open"]
429
+ # An Instagram/Facebook DM past Meta's 24-hour window: reply would raise
430
+ # 422 outside_messaging_window. Answer it in the app, or skip it.
431
+ client.inbox.mark_read(message["conversation_id"])
432
+ item = client.inbox.next(platform: "instagram")
433
+ next
434
+ end
435
+
436
+ reply = client.inbox.reply(
437
+ message["conversation_id"],
438
+ text: "Thanks! DM sent.",
439
+ message_id: message["id"], # the comment being answered, not the newest one
440
+ include_next: true
441
+ )
442
+ item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
443
+ end
444
+ ```
402
445
 
403
446
  Replying to an X DM costs 2 prepaid credits, debited from the company balance before the send and automatically refunded if the send fails:
404
447
 
@@ -421,7 +464,7 @@ end
421
464
 
422
465
  ## Webhooks
423
466
 
424
- Subscribe to `post.scheduled`, `post.published`, and `post.failed` events:
467
+ 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"]`.
425
468
 
426
469
  ```ruby
427
470
  webhook = client.webhooks.create(
@@ -464,6 +507,8 @@ class OmnisocialsWebhooksController < ApplicationController
464
507
  event["data"]["targets"].each do |target|
465
508
  Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
466
509
  end
510
+ elsif event["type"] == "post.rejected"
511
+ Rails.logger.info "Rejected: #{event["data"]["post_id"]} #{event["data"]["approval"]["reason"]}"
467
512
  end
468
513
 
469
514
  head :ok
@@ -59,8 +59,8 @@ module OmniSocials
59
59
  ].freeze
60
60
 
61
61
  attr_reader :api_key, :base_url, :timeout, :max_retries,
62
- :posts, :media, :folders, :hashtag_sets, :accounts, :analytics,
63
- :audio, :locations, :inbox, :webhooks
62
+ :posts, :media, :folders, :hashtag_sets, :approval_workflows,
63
+ :accounts, :analytics, :audio, :locations, :inbox, :webhooks
64
64
 
65
65
  # api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
66
66
  # OMNISOCIALS_API_KEY environment variable. Raises
@@ -89,6 +89,7 @@ module OmniSocials
89
89
  @media = Resources::Media.new(self)
90
90
  @folders = Resources::Folders.new(self)
91
91
  @hashtag_sets = Resources::HashtagSets.new(self)
92
+ @approval_workflows = Resources::ApprovalWorkflows.new(self)
92
93
  @accounts = Resources::Accounts.new(self)
93
94
  @analytics = Resources::Analytics.new(self)
94
95
  @audio = Resources::Audio.new(self)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniSocials
4
+ module Resources
5
+ # Approval workflows resource: the workflows configured in the dashboard
6
+ # (Approvals). List them here and route a post through one at create time
7
+ # via approval_workflow_id on posts.create.
8
+ class ApprovalWorkflows
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # GET /approval-workflows - the workflows this workspace can use
14
+ # (company-wide plus workspace-bound), with steps and named approvers.
15
+ def list
16
+ @client.request("GET", "/approval-workflows")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -24,24 +24,31 @@ module OmniSocials
24
24
  #
25
25
  # All filters are optional: platform ("instagram", "facebook",
26
26
  # "linkedin", "tiktok", "youtube", "x", "threads"), type ("dm", "comment",
27
- # "mention"), unread (only conversations with unread messages), limit
28
- # (1-100), and cursor (an opaque cursor from a previous response's
27
+ # "mention"), unread (only conversations with unread messages),
28
+ # unanswered (only conversations that still need an answer: the
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
29
36
  # pagination["next_cursor"]).
30
37
  #
31
38
  # Threads conversations are type "comment" (replies people leave on the
32
39
  # user's Threads posts; conversation ids look like
33
40
  # "threads_comment_<rootPostId>") and "mention"
34
41
  # ("threads_mention_<postId>"); there are no Threads DMs. Threads inbox
35
- # is currently rolling out: until Meta approves the permissions it is
36
- # disabled on production and calls return a clear error, and it needs a
37
- # Threads connection with the reply permission.
38
- def list_conversations(platform: nil, type: nil, unread: nil, limit: nil, cursor: nil)
42
+ # needs a Threads connection with the reply permissions; connections made
43
+ # before those permissions existed must be reconnected once.
44
+ def list_conversations(platform: nil, type: nil, unread: nil, unanswered: nil, limit: nil, cursor: nil)
39
45
  @client.request(
40
46
  "GET", "/inbox/conversations",
41
47
  query: {
42
48
  "platform" => platform,
43
49
  "type" => type,
44
50
  "unread" => unread,
51
+ "unanswered" => unanswered,
45
52
  "limit" => limit,
46
53
  "cursor" => cursor
47
54
  }
@@ -73,10 +80,9 @@ module OmniSocials
73
80
  # text-only. Returns the created outgoing message.
74
81
  #
75
82
  # On a Threads conversation the reply publishes as a native Threads
76
- # reply. Threads inbox is currently rolling out (disabled on production
77
- # until Meta App Review) and needs a Threads connection with the reply
83
+ # reply. The Threads inbox needs a Threads connection with the reply
78
84
  # permission: a 401 with code "reauth_required" means the connection
79
- # lacks that permission (reconnect Threads).
85
+ # lacks that permission (connected before it existed; reconnect Threads).
80
86
  #
81
87
  # Replying to an X DM costs 2 prepaid credits, debited from the
82
88
  # company balance before the send and automatically refunded if the
@@ -86,12 +92,35 @@ module OmniSocials
86
92
  # auto-suspended after the balance hit zero (top up and re-enable it
87
93
  # in the dashboard to resume; DMs that arrived while suspended are
88
94
  # not recovered).
89
- def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil)
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
+ #
111
+ # Pass include_next: true to also get "next" (the next conversation
112
+ # that needs an answer, the same object `next` returns under "data",
113
+ # using its default queue order and filters; nil when nothing is
114
+ # waiting) and "remaining" in the response. Saves the extra call when
115
+ # working through the inbox.
116
+ def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, message_id: nil, include_next: nil)
90
117
  body = Internal.drop_nil(
91
118
  {
92
119
  "text" => text,
93
120
  "attachment_url" => attachment_url,
94
- "attachment_type" => attachment_type
121
+ "attachment_type" => attachment_type,
122
+ "message_id" => message_id,
123
+ "include_next" => include_next
95
124
  }
96
125
  )
97
126
  @client.request(
@@ -100,18 +129,37 @@ module OmniSocials
100
129
  )
101
130
  end
102
131
 
103
- # POST /inbox/messages/{id}/hide - hide or unhide a reply someone left
104
- # on one of the user's Threads posts, as the post owner (Threads only
105
- # for now). Pass hide: false to unhide. Only incoming top-level replies
106
- # can be hidden (Threads does not allow hiding nested replies); the
107
- # message keeps its place in the conversation. Returns the updated
108
- # message with its "hidden" flag flipped.
132
+ # POST /inbox/messages/{id}/hide - hide or unhide a comment someone
133
+ # left on one of the user's posts, on the platform, as the post owner.
134
+ # Facebook, Instagram, TikTok, YouTube and Threads comments (Threads:
135
+ # incoming top-level replies only; Threads does not allow hiding nested
136
+ # replies). Pass hide: false to unhide. On YouTube, hide sets the
137
+ # comment's moderation status to rejected, which removes it and its
138
+ # replies from public view; unhide publishes it again. Returns the
139
+ # updated message with its "hidden" flag flipped; the message keeps its
140
+ # place in the conversation, and a hidden comment no longer counts as
141
+ # unanswered. The account must have been connected with the moderation
142
+ # permission (Facebook pages_manage_engagement, Instagram
143
+ # instagram_business_manage_comments).
109
144
  #
110
- # Errors: 400 "unsupported_platform" (not an incoming Threads reply, or
111
- # Threads inbox not available yet), 400 "not_hideable" (nested reply or
112
- # Threads refused), 401 "reauth_required" (connection lacks the reply
113
- # permission; reconnect Threads), 404 "not_found" (message not in this
114
- # workspace) or "account_not_connected" (no Threads account).
145
+ # Errors: 400 "unsupported_platform" (not an incoming comment on a
146
+ # supported platform), 400 "not_hideable" (Threads nested reply, or
147
+ # Threads refused), 401 "reauth_required" (the Threads reply permission
148
+ # or the TikTok comments authorization is missing or expired), 403
149
+ # "reconnect_required" (the account was connected without the
150
+ # comment-moderation permission; reconnect it in the dashboard), 404
151
+ # "not_found" (message not in this workspace) or
152
+ # "account_not_connected", 429 "quota_exceeded" (YouTube's daily API
153
+ # quota is used up; retry after midnight Pacific), 502 "platform_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".
115
163
  def hide(message_id, hide: true)
116
164
  @client.request(
117
165
  "POST", "/inbox/messages/#{encode_id(message_id)}/hide",
@@ -119,6 +167,84 @@ module OmniSocials
119
167
  )
120
168
  end
121
169
 
170
+ # DELETE /inbox/messages/{id} - delete a comment someone left on one of
171
+ # the user's posts, on the platform and from the inbox. Facebook,
172
+ # Instagram and TikTok comments only: YouTube's API does not let a
173
+ # channel delete other people's comments, hide those instead (`hide`).
174
+ # Replies under the deleted comment go with it (the platforms cascade
175
+ # the delete and the inbox mirrors that); their inbox ids come back as
176
+ # "removed_reply_ids". A comment that is already gone on the platform
177
+ # is still removed from the inbox. This cannot be undone. Returns
178
+ # { "data" => { "id", "conversation_id", "removed_reply_ids" } }.
179
+ #
180
+ # Errors: 400 "unsupported_platform" (not an incoming Facebook,
181
+ # Instagram or TikTok comment), 401 "reauth_required" (the TikTok
182
+ # comments authorization expired), 403 "reconnect_required" (the
183
+ # account was connected without the comment-moderation permission;
184
+ # reconnect it in the dashboard), 404 "not_found" (message not in this
185
+ # workspace) or "account_not_connected", 502 "platform_error" (the
186
+ # platform rejected the call).
187
+ def delete_message(message_id)
188
+ @client.request("DELETE", "/inbox/messages/#{encode_id(message_id)}")
189
+ end
190
+
191
+ # GET /inbox/next - the next conversation that needs an answer: a work
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
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.
203
+ # Replies typed in the native apps count as answers (they are mirrored
204
+ # into the inbox), so a thread a colleague answered on their phone is
205
+ # not served again. Instagram mentions are skipped (no reply path).
206
+ # Looks at the last 30 days of activity. Requires the inbox:read scope.
207
+ #
208
+ # Only unread items are served by default: marking a conversation read
209
+ # (`mark_read`) is how to skip one for good; pass include_read: true to
210
+ # include read-but-unanswered items. exclude is a session-local skip:
211
+ # conversation ids (an Array, or a comma-separated String) to leave out
212
+ # of this call, up to 100. order is "oldest" (default: the item that
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.
216
+ #
217
+ # Returns { "data" => ..., "remaining" => Integer }. "data" is
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
228
+ # number of unanswered items still waiting after this one (capped at
229
+ # 500), 0 when "data" is nil. To chain the queue, pass
230
+ # include_next: true to `reply` and it returns the next item in the
231
+ # same response. Errors: 400 "validation_error" (unknown platform, type
232
+ # or order).
233
+ def next(platform: nil, type: nil, order: nil, include_read: nil, exclude: nil)
234
+ exclude = exclude.join(",") if exclude.is_a?(Array)
235
+ exclude = nil if exclude == ""
236
+ @client.request(
237
+ "GET", "/inbox/next",
238
+ query: {
239
+ "platform" => platform,
240
+ "type" => type,
241
+ "order" => order,
242
+ "include_read" => include_read,
243
+ "exclude" => exclude
244
+ }
245
+ )
246
+ end
247
+
122
248
  private
123
249
 
124
250
  # URL-encode a conversation or message id for use in a path segment.
@@ -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)
@@ -46,6 +46,13 @@ module OmniSocials
46
46
  # POST /posts/create - create a post (draft, or scheduled when
47
47
  # scheduled_at is set).
48
48
  #
49
+ # approval_workflow_id (a workflow id from client.approval_workflows.list)
50
+ # routes the post through a saved approval workflow: it is created as
51
+ # in_approval (approval_status "pending") instead of scheduled, the
52
+ # approvers are notified, and it publishes at scheduled_at once the last
53
+ # step approves. Requires scheduled_at; not allowed with publish_now.
54
+ # Errors: 404 workflow_not_found, 400 validation_error.
55
+ #
49
56
  # hashtag_set (set name, case-insensitive) or hashtag_set_id applies a
50
57
  # saved hashtag set once at create time; tags already in a caption are
51
58
  # skipped; Instagram's 30-hashtag cap returns error code
@@ -88,7 +95,7 @@ module OmniSocials
88
95
  instagram: nil, facebook: nil, linkedin: nil,
89
96
  linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
90
97
  mastodon: nil, threads: nil, google_business: nil,
91
- linkedin_poll: nil)
98
+ linkedin_poll: nil, approval_workflow_id: nil)
92
99
  body = create_body(
93
100
  content: content, channels: channels, scheduled_at: scheduled_at,
94
101
  media_ids: media_ids, media_urls: media_urls, type: type,
@@ -101,7 +108,8 @@ module OmniSocials
101
108
  youtube: youtube, instagram: instagram, facebook: facebook,
102
109
  linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
103
110
  x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
104
- google_business: google_business, linkedin_poll: linkedin_poll
111
+ google_business: google_business, linkedin_poll: linkedin_poll,
112
+ approval_workflow_id: approval_workflow_id
105
113
  )
106
114
  @client.request("POST", "/posts/create", json: body)
107
115
  end
@@ -184,7 +192,7 @@ module OmniSocials
184
192
  @client.request("PATCH", "/posts/#{post_id}", json: body)
185
193
  end
186
194
 
187
- # DELETE /posts/{id} - delete a post. Returns nil (204).
195
+ # DELETE /posts/{id} - remove a post from OmniSocials (the live post stays on the platform). Returns nil (204).
188
196
  def delete(post_id)
189
197
  @client.request("DELETE", "/posts/#{post_id}")
190
198
  end
@@ -230,6 +238,22 @@ module OmniSocials
230
238
  @client.request("POST", "/posts/#{post_id}/reject", json: body)
231
239
  end
232
240
 
241
+ # GET /posts/{id}/approval - the approval review of a post: every step
242
+ # with its approvers and their decisions, the rejection with its
243
+ # reason, and the comment thread. Use it when approval_status is
244
+ # "rejected" to learn who rejected the post and why, or while it is
245
+ # "pending" to see who the post waits for.
246
+ #
247
+ # `data` carries post_id, status ("none", "pending", "approved",
248
+ # "rejected"), workflow, requested_by, requested_at, current_step,
249
+ # steps, rejection and comments (oldest first). A post without an
250
+ # approval workflow returns status "none" with the object fields nil
251
+ # and empty steps and comments. Read-only; requires the posts:read
252
+ # scope.
253
+ def get_approval(post_id)
254
+ @client.request("GET", "/posts/#{post_id}/approval")
255
+ end
256
+
233
257
  private
234
258
 
235
259
  def create_body(content:, channels:, scheduled_at:, media_ids:,
@@ -239,7 +263,8 @@ module OmniSocials
239
263
  hashtag_set_id:, hashtag_placement:, hashtag_platforms:,
240
264
  pinterest:, youtube:, instagram:, facebook:, linkedin:,
241
265
  linkedin_page:, tiktok:, x:, bluesky:, mastodon:,
242
- threads:, google_business:, linkedin_poll:)
266
+ threads:, google_business:, linkedin_poll:,
267
+ approval_workflow_id: nil)
243
268
  Internal.drop_nil(
244
269
  {
245
270
  "content" => content,
@@ -272,7 +297,8 @@ module OmniSocials
272
297
  "mastodon" => mastodon,
273
298
  "threads" => threads,
274
299
  "google_business" => google_business,
275
- "linkedin_poll" => linkedin_poll
300
+ "linkedin_poll" => linkedin_poll,
301
+ "approval_workflow_id" => approval_workflow_id
276
302
  }
277
303
  )
278
304
  end
@@ -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.5.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/omnisocials.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "omnisocials/resources/posts"
20
20
  require_relative "omnisocials/resources/media"
21
21
  require_relative "omnisocials/resources/folders"
22
22
  require_relative "omnisocials/resources/hashtag_sets"
23
+ require_relative "omnisocials/resources/approval_workflows"
23
24
  require_relative "omnisocials/resources/accounts"
24
25
  require_relative "omnisocials/resources/analytics"
25
26
  require_relative "omnisocials/resources/audio"
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.5.0
4
+ version: 0.8.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-08-30 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,
@@ -27,6 +27,7 @@ files:
27
27
  - lib/omnisocials/internal.rb
28
28
  - lib/omnisocials/resources/accounts.rb
29
29
  - lib/omnisocials/resources/analytics.rb
30
+ - lib/omnisocials/resources/approval_workflows.rb
30
31
  - lib/omnisocials/resources/audio.rb
31
32
  - lib/omnisocials/resources/folders.rb
32
33
  - lib/omnisocials/resources/hashtag_sets.rb