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 +4 -4
- data/README.md +51 -6
- data/lib/omnisocials/client.rb +3 -2
- data/lib/omnisocials/resources/approval_workflows.rb +20 -0
- data/lib/omnisocials/resources/inbox.rb +148 -22
- data/lib/omnisocials/resources/media.rb +11 -7
- data/lib/omnisocials/resources/posts.rb +31 -5
- 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: 43d0cc71e753d98ddbaa8b1732f140ecfc3d578d02a8e8df81e248c6c9c75131
|
|
4
|
+
data.tar.gz: a66ae890324f9adc7b37b57add4e76ce1be1b110d4b15f751c611bcbfa6adf61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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]["
|
|
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
|
-
#
|
|
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.
|
|
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.
|
|
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
|
data/lib/omnisocials/client.rb
CHANGED
|
@@ -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, :
|
|
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),
|
|
28
|
-
# (
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
104
|
-
# on one of the user's
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
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
|
|
111
|
-
#
|
|
112
|
-
# Threads refused), 401 "reauth_required" (
|
|
113
|
-
#
|
|
114
|
-
#
|
|
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
|
|
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} -
|
|
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.
|
|
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
|
@@ -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.
|
|
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-
|
|
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
|