omnisocials 0.4.0 → 0.6.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 +69 -5
- data/lib/omnisocials/client.rb +3 -2
- data/lib/omnisocials/resources/approval_workflows.rb +20 -0
- data/lib/omnisocials/resources/inbox.rb +142 -13
- data/lib/omnisocials/resources/locations.rb +37 -5
- data/lib/omnisocials/resources/posts.rb +64 -13
- 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: 4b3fed727cadc18b6ea190937598241ea06e5ac3451f5a01e47e993f641f5332
|
|
4
|
+
data.tar.gz: 6adeeba6ddc69911b79c4b620ddc418e0dd372d6e76e3a417bd285ae54b44e9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ad4c11a0f91aa25cd0dfab676f0a28b5973d139a82137c0d753de697a453d710bd9f59f6c212596230fe727e37d20e117635d7b23d06f2f6aeeefce3d66b5ffa
|
|
7
|
+
data.tar.gz: 1b53388871737de150a7d71387472915b3e1bfcc7c8e245f45e5c3d87ea4ef2ee9d8047855876d33e1028c0b24272accfe663ef482e83bbdc4e60a72fe9268af
|
data/README.md
CHANGED
|
@@ -103,9 +103,9 @@ post = client.posts.create(
|
|
|
103
103
|
|
|
104
104
|
Note that `linkedin` targets a personal LinkedIn profile and `linkedin_page` targets a LinkedIn company page. Both can be connected to the same workspace and posted to independently.
|
|
105
105
|
|
|
106
|
-
### X
|
|
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 and
|
|
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(
|
|
@@ -123,7 +123,24 @@ post = client.posts.create(
|
|
|
123
123
|
)
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
```ruby
|
|
127
|
+
# Meta Threads chain with a carousel on the first part
|
|
128
|
+
post = client.posts.create(
|
|
129
|
+
content: "Behind the scenes of our summer shoot",
|
|
130
|
+
channels: ["threads"],
|
|
131
|
+
threads: {
|
|
132
|
+
"thread_parts" => [
|
|
133
|
+
{ "text" => "Behind the scenes of our summer shoot. A few highlights:", "media_urls" => ["https://example.com/shoot-1.jpg", "https://example.com/shoot-2.jpg"] },
|
|
134
|
+
{ "text" => "Day one: scouting locations at sunrise." },
|
|
135
|
+
{ "text" => "Day two: the full crew, 14 hours, zero regrets." }
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
On update, passing `x: { "thread_parts" => nil }` clears the thread and reverts the post to single-tweet mode (same for `bluesky`, `mastodon` and `threads`). Only top-level `nil` values are dropped from request bodies, so nested `nil` values like this one are sent as JSON `null`.
|
|
142
|
+
|
|
143
|
+
Threads posts can also carry a location tag: pass `threads: { "location_id" => "..." }` with an id from `client.locations.search(platform: "threads")` (see Locations below). On a multi-post thread the tag is applied to part 1, and on update `threads: { "location_id" => nil }` clears it. Threads location tagging is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error.
|
|
127
144
|
|
|
128
145
|
### X link posts use credits
|
|
129
146
|
|
|
@@ -182,6 +199,15 @@ recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tikto
|
|
|
182
199
|
|
|
183
200
|
`retry` re-publishes only the platforms that failed, on the same post; platforms that already succeeded are never posted again. It is asynchronous: a 200 means the retry is queued, so poll `get` for the outcome. Max 3 retries per platform.
|
|
184
201
|
|
|
202
|
+
### Approve or reject a post
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
client.posts.approve("123") # approve the current approval-workflow step
|
|
206
|
+
client.posts.reject("123", comment: "Wrong CTA link, please fix.") # reject and stop the workflow (comment optional)
|
|
207
|
+
```
|
|
208
|
+
|
|
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
|
+
|
|
185
211
|
## Media
|
|
186
212
|
|
|
187
213
|
### Upload from a URL (recommended, up to 1GB)
|
|
@@ -315,7 +341,7 @@ best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amst
|
|
|
315
341
|
best["data"]["best_times"].each { |slot| puts slot }
|
|
316
342
|
```
|
|
317
343
|
|
|
318
|
-
## Locations (Instagram place tagging)
|
|
344
|
+
## Locations (Instagram and Threads place tagging)
|
|
319
345
|
|
|
320
346
|
```ruby
|
|
321
347
|
results = client.locations.search("Blue Bottle Coffee Oakland")
|
|
@@ -330,6 +356,23 @@ client.posts.create(
|
|
|
330
356
|
)
|
|
331
357
|
```
|
|
332
358
|
|
|
359
|
+
Threads uses its own location ids (a Facebook Place ID is not a Threads location id). Pass `platform: "threads"` and search by keyword, or by `latitude` plus `longitude` instead of `q`; use a result's `id` as `threads.location_id` on a post:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
results = client.locations.search("Blue Bottle Coffee Oakland", platform: "threads")
|
|
363
|
+
# or around a point instead of a keyword:
|
|
364
|
+
results = client.locations.search(platform: "threads", latitude: 37.8044, longitude: -122.2712)
|
|
365
|
+
threads_location_id = results["locations"][0]["id"]
|
|
366
|
+
|
|
367
|
+
client.posts.create(
|
|
368
|
+
content: "Great coffee here",
|
|
369
|
+
channels: ["threads"],
|
|
370
|
+
threads: { "location_id" => threads_location_id }
|
|
371
|
+
)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
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
|
+
|
|
333
376
|
## Inbox
|
|
334
377
|
|
|
335
378
|
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`.
|
|
@@ -348,9 +391,30 @@ end
|
|
|
348
391
|
|
|
349
392
|
client.inbox.mark_read(conversation_id)
|
|
350
393
|
client.inbox.reply(conversation_id, text: "Thanks for reaching out!")
|
|
394
|
+
|
|
395
|
+
# Hide or unhide a comment someone left on one of your posts (Facebook,
|
|
396
|
+
# Instagram, TikTok, YouTube, Threads). Delete removes it outright (Facebook,
|
|
397
|
+
# Instagram, TikTok; YouTube: hide instead), replies under it included.
|
|
398
|
+
message_id = messages["data"][0]["id"]
|
|
399
|
+
client.inbox.hide(message_id) # hide
|
|
400
|
+
client.inbox.hide(message_id, hide: false) # unhide
|
|
401
|
+
client.inbox.delete_message(message_id)
|
|
351
402
|
```
|
|
352
403
|
|
|
353
|
-
`platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`,
|
|
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"`).
|
|
405
|
+
|
|
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.
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
item = client.inbox.next(platform: "instagram")
|
|
410
|
+
while item["data"]
|
|
411
|
+
message = item["data"]["message"]
|
|
412
|
+
puts "#{item["remaining"]} left. #{message["sender"]["username"]}: #{message["text"]}"
|
|
413
|
+
|
|
414
|
+
reply = client.inbox.reply(message["conversation_id"], text: "Thanks! DM sent.", include_next: true)
|
|
415
|
+
item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
|
|
416
|
+
end
|
|
417
|
+
```
|
|
354
418
|
|
|
355
419
|
Replying to an X DM costs 2 prepaid credits, debited from the company balance before the send and automatically refunded if the send fails:
|
|
356
420
|
|
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
|
|
@@ -23,17 +23,30 @@ module OmniSocials
|
|
|
23
23
|
# GET /inbox/conversations - list conversations, newest activity first.
|
|
24
24
|
#
|
|
25
25
|
# All filters are optional: platform ("instagram", "facebook",
|
|
26
|
-
# "linkedin", "tiktok", "x"), type ("dm", "comment",
|
|
27
|
-
# unread (only conversations with unread messages),
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
26
|
+
# "linkedin", "tiktok", "youtube", "x", "threads"), type ("dm", "comment",
|
|
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, 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"]).
|
|
35
|
+
#
|
|
36
|
+
# Threads conversations are type "comment" (replies people leave on the
|
|
37
|
+
# user's Threads posts; conversation ids look like
|
|
38
|
+
# "threads_comment_<rootPostId>") and "mention"
|
|
39
|
+
# ("threads_mention_<postId>"); there are no Threads DMs. Threads inbox
|
|
40
|
+
# needs a Threads connection with the reply permissions; connections made
|
|
41
|
+
# before those permissions existed must be reconnected once.
|
|
42
|
+
def list_conversations(platform: nil, type: nil, unread: nil, unanswered: nil, limit: nil, cursor: nil)
|
|
31
43
|
@client.request(
|
|
32
44
|
"GET", "/inbox/conversations",
|
|
33
45
|
query: {
|
|
34
46
|
"platform" => platform,
|
|
35
47
|
"type" => type,
|
|
36
48
|
"unread" => unread,
|
|
49
|
+
"unanswered" => unanswered,
|
|
37
50
|
"limit" => limit,
|
|
38
51
|
"cursor" => cursor
|
|
39
52
|
}
|
|
@@ -58,9 +71,16 @@ module OmniSocials
|
|
|
58
71
|
# POST /inbox/conversations/{id}/reply - send a reply into the
|
|
59
72
|
# conversation (a DM message, or a reply to the comment/mention).
|
|
60
73
|
#
|
|
61
|
-
#
|
|
62
|
-
# URL with `attachment_url` plus `attachment_type` ("image",
|
|
63
|
-
# "audio", or "file")
|
|
74
|
+
# On Facebook and Instagram DMs, optionally attach a single media asset
|
|
75
|
+
# by public URL with `attachment_url` plus `attachment_type` ("image",
|
|
76
|
+
# "video", "audio", or "file"); `text` is optional when `attachment_url`
|
|
77
|
+
# is set (an attachment-only reply is allowed). Other platforms are
|
|
78
|
+
# text-only. Returns the created outgoing message.
|
|
79
|
+
#
|
|
80
|
+
# On a Threads conversation the reply publishes as a native Threads
|
|
81
|
+
# reply. The Threads inbox needs a Threads connection with the reply
|
|
82
|
+
# permission: a 401 with code "reauth_required" means the connection
|
|
83
|
+
# lacks that permission (connected before it existed; reconnect Threads).
|
|
64
84
|
#
|
|
65
85
|
# Replying to an X DM costs 2 prepaid credits, debited from the
|
|
66
86
|
# company balance before the send and automatically refunded if the
|
|
@@ -70,12 +90,19 @@ module OmniSocials
|
|
|
70
90
|
# auto-suspended after the balance hit zero (top up and re-enable it
|
|
71
91
|
# in the dashboard to resume; DMs that arrived while suspended are
|
|
72
92
|
# not recovered).
|
|
73
|
-
|
|
93
|
+
#
|
|
94
|
+
# Pass include_next: true to also get "next" (the next conversation
|
|
95
|
+
# that needs an answer, the same object `next` returns under "data",
|
|
96
|
+
# using its default queue order and filters; nil when nothing is
|
|
97
|
+
# waiting) and "remaining" in the response. Saves the extra call when
|
|
98
|
+
# working through the inbox.
|
|
99
|
+
def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, include_next: nil)
|
|
74
100
|
body = Internal.drop_nil(
|
|
75
101
|
{
|
|
76
102
|
"text" => text,
|
|
77
103
|
"attachment_url" => attachment_url,
|
|
78
|
-
"attachment_type" => attachment_type
|
|
104
|
+
"attachment_type" => attachment_type,
|
|
105
|
+
"include_next" => include_next
|
|
79
106
|
}
|
|
80
107
|
)
|
|
81
108
|
@client.request(
|
|
@@ -84,11 +111,113 @@ module OmniSocials
|
|
|
84
111
|
)
|
|
85
112
|
end
|
|
86
113
|
|
|
114
|
+
# POST /inbox/messages/{id}/hide - hide or unhide a comment someone
|
|
115
|
+
# left on one of the user's posts, on the platform, as the post owner.
|
|
116
|
+
# Facebook, Instagram, TikTok, YouTube and Threads comments (Threads:
|
|
117
|
+
# incoming top-level replies only; Threads does not allow hiding nested
|
|
118
|
+
# replies). Pass hide: false to unhide. On YouTube, hide sets the
|
|
119
|
+
# comment's moderation status to rejected, which removes it and its
|
|
120
|
+
# replies from public view; unhide publishes it again. Returns the
|
|
121
|
+
# updated message with its "hidden" flag flipped; the message keeps its
|
|
122
|
+
# place in the conversation, and a hidden comment no longer counts as
|
|
123
|
+
# unanswered. The account must have been connected with the moderation
|
|
124
|
+
# permission (Facebook pages_manage_engagement, Instagram
|
|
125
|
+
# instagram_business_manage_comments).
|
|
126
|
+
#
|
|
127
|
+
# Errors: 400 "unsupported_platform" (not an incoming comment on a
|
|
128
|
+
# supported platform), 400 "not_hideable" (Threads nested reply, or
|
|
129
|
+
# Threads refused), 401 "reauth_required" (the Threads reply permission
|
|
130
|
+
# or the TikTok comments authorization is missing or expired), 403
|
|
131
|
+
# "reconnect_required" (the account was connected without the
|
|
132
|
+
# comment-moderation permission; reconnect it in the dashboard), 404
|
|
133
|
+
# "not_found" (message not in this workspace) or
|
|
134
|
+
# "account_not_connected", 429 "quota_exceeded" (YouTube's daily API
|
|
135
|
+
# 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.
|
|
139
|
+
def hide(message_id, hide: true)
|
|
140
|
+
@client.request(
|
|
141
|
+
"POST", "/inbox/messages/#{encode_id(message_id)}/hide",
|
|
142
|
+
json: { "hide" => hide }
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# DELETE /inbox/messages/{id} - delete a comment someone left on one of
|
|
147
|
+
# the user's posts, on the platform and from the inbox. Facebook,
|
|
148
|
+
# Instagram and TikTok comments only: YouTube's API does not let a
|
|
149
|
+
# channel delete other people's comments, hide those instead (`hide`).
|
|
150
|
+
# Replies under the deleted comment go with it (the platforms cascade
|
|
151
|
+
# the delete and the inbox mirrors that); their inbox ids come back as
|
|
152
|
+
# "removed_reply_ids". A comment that is already gone on the platform
|
|
153
|
+
# is still removed from the inbox. This cannot be undone. Returns
|
|
154
|
+
# { "data" => { "id", "conversation_id", "removed_reply_ids" } }.
|
|
155
|
+
#
|
|
156
|
+
# Errors: 400 "unsupported_platform" (not an incoming Facebook,
|
|
157
|
+
# Instagram or TikTok comment), 401 "reauth_required" (the TikTok
|
|
158
|
+
# comments authorization expired), 403 "reconnect_required" (the
|
|
159
|
+
# account was connected without the comment-moderation permission;
|
|
160
|
+
# reconnect it in the dashboard), 404 "not_found" (message not in this
|
|
161
|
+
# workspace) or "account_not_connected", 502 "platform_error" (the
|
|
162
|
+
# platform rejected the call).
|
|
163
|
+
def delete_message(message_id)
|
|
164
|
+
@client.request("DELETE", "/inbox/messages/#{encode_id(message_id)}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# 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
|
|
174
|
+
# comment/mention that has not been replied to and is not hidden.
|
|
175
|
+
# Replies typed in the native apps count as answers (they are mirrored
|
|
176
|
+
# into the inbox), so a thread a colleague answered on their phone is
|
|
177
|
+
# not served again. Instagram mentions are skipped (no reply path).
|
|
178
|
+
# Looks at the last 30 days of activity. Requires the inbox:read scope.
|
|
179
|
+
#
|
|
180
|
+
# Only unread items are served by default: marking a conversation read
|
|
181
|
+
# (`mark_read`) is how to skip one for good; pass include_read: true to
|
|
182
|
+
# include read-but-unanswered items. exclude is a session-local skip:
|
|
183
|
+
# conversation ids (an Array, or a comma-separated String) to leave out
|
|
184
|
+
# 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.
|
|
187
|
+
#
|
|
188
|
+
# 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
|
|
195
|
+
# number of unanswered items still waiting after this one (capped at
|
|
196
|
+
# 500), 0 when "data" is nil. To chain the queue, pass
|
|
197
|
+
# include_next: true to `reply` and it returns the next item in the
|
|
198
|
+
# same response. Errors: 400 "validation_error" (unknown platform, type
|
|
199
|
+
# or order).
|
|
200
|
+
def next(platform: nil, type: nil, order: nil, include_read: nil, exclude: nil)
|
|
201
|
+
exclude = exclude.join(",") if exclude.is_a?(Array)
|
|
202
|
+
exclude = nil if exclude == ""
|
|
203
|
+
@client.request(
|
|
204
|
+
"GET", "/inbox/next",
|
|
205
|
+
query: {
|
|
206
|
+
"platform" => platform,
|
|
207
|
+
"type" => type,
|
|
208
|
+
"order" => order,
|
|
209
|
+
"include_read" => include_read,
|
|
210
|
+
"exclude" => exclude
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
87
215
|
private
|
|
88
216
|
|
|
89
|
-
# URL-encode a conversation id for use in a path segment.
|
|
90
|
-
# contain ":" and "()", so they must be
|
|
91
|
-
# (a path segment treats "+" literally,
|
|
217
|
+
# URL-encode a conversation or message id for use in a path segment.
|
|
218
|
+
# LinkedIn conversation ids contain ":" and "()", so they must be
|
|
219
|
+
# escaped; spaces become %20 (a path segment treats "+" literally,
|
|
220
|
+
# unlike a query string).
|
|
92
221
|
def encode_id(conversation_id)
|
|
93
222
|
CGI.escape(conversation_id.to_s).gsub("+", "%20")
|
|
94
223
|
end
|
|
@@ -2,16 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
module OmniSocials
|
|
4
4
|
module Resources
|
|
5
|
-
# Locations resource: Instagram place tagging (search +
|
|
5
|
+
# Locations resource: Instagram and Threads place tagging (search +
|
|
6
|
+
# validate).
|
|
6
7
|
class Locations
|
|
7
8
|
def initialize(client)
|
|
8
9
|
@client = client
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
# GET /locations/search
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# GET /locations/search - search locations for place tagging.
|
|
13
|
+
#
|
|
14
|
+
# platform is "instagram" (default) or "threads". The two sources use
|
|
15
|
+
# DIFFERENT ids: a Facebook Place ID is not a Threads location id.
|
|
16
|
+
#
|
|
17
|
+
# Instagram: pass q to search Facebook place pages usable as an
|
|
18
|
+
# Instagram location_id. Response: { "data" => [...] } plus optional
|
|
19
|
+
# "error" (a plain string on the degraded path) and "needsPermission".
|
|
20
|
+
#
|
|
21
|
+
# Threads: pass q, OR latitude (-90..90) plus longitude (-180..180) to
|
|
22
|
+
# search around a point instead of q. Response:
|
|
23
|
+
# { "locations" => [{ id, name, address, city, country, latitude,
|
|
24
|
+
# longitude }] } (all fields but id nullable), or
|
|
25
|
+
# { "error" => { "code", "message" } } where code is one of
|
|
26
|
+
# "not_available" (Threads location tagging not enabled in this
|
|
27
|
+
# environment yet), "threads_not_connected", "threads_reauth_required"
|
|
28
|
+
# (the connection lacks the threads_location_tagging permission;
|
|
29
|
+
# reconnect Threads), or "platform_error". Validation problems (neither
|
|
30
|
+
# q nor lat+lng, q under 2 chars, coordinates out of range) raise a 400
|
|
31
|
+
# with the standard error envelope. Pass a result's id as
|
|
32
|
+
# threads.location_id on post create/update.
|
|
33
|
+
#
|
|
34
|
+
# Threads location tagging is currently rolling out: until Meta
|
|
35
|
+
# approves the permissions it is disabled on production and calls
|
|
36
|
+
# return a clear error.
|
|
37
|
+
def search(q = nil, platform: nil, latitude: nil, longitude: nil)
|
|
38
|
+
@client.request(
|
|
39
|
+
"GET", "/locations/search",
|
|
40
|
+
query: {
|
|
41
|
+
"q" => q,
|
|
42
|
+
"platform" => platform,
|
|
43
|
+
"latitude" => latitude,
|
|
44
|
+
"longitude" => longitude
|
|
45
|
+
}
|
|
46
|
+
)
|
|
15
47
|
end
|
|
16
48
|
|
|
17
49
|
# GET /locations/validate?id= - validate a location id before attaching
|
|
@@ -11,8 +11,8 @@ module OmniSocials
|
|
|
11
11
|
# for media_urls, `{ "id" => "...", "alt" => "..." }` for media_ids. Alt
|
|
12
12
|
# text is delivered to Mastodon (media description), Bluesky (embed alt),
|
|
13
13
|
# X (photos/GIFs), Pinterest (pin alt text), Instagram (images), and
|
|
14
|
-
# LinkedIn (images); the same entry shape works inside
|
|
15
|
-
# `thread_parts` media.
|
|
14
|
+
# LinkedIn (images); the same entry shape works inside
|
|
15
|
+
# x/bluesky/mastodon/threads `thread_parts` media.
|
|
16
16
|
class Posts
|
|
17
17
|
def initialize(client)
|
|
18
18
|
@client = client
|
|
@@ -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
|
|
@@ -67,6 +74,18 @@ module OmniSocials
|
|
|
67
74
|
# company's total reserved credits past its balance. Drafts are never
|
|
68
75
|
# gated, and posts scheduled to publish before 2026-08-14 are never
|
|
69
76
|
# gated either.
|
|
77
|
+
#
|
|
78
|
+
# Threads posts can carry a location tag: pass
|
|
79
|
+
# threads: { "location_id" => "..." } (an id from locations.search with
|
|
80
|
+
# platform: "threads"), or threads: { "location" => { "id" => "...",
|
|
81
|
+
# "name" => "..." } } to store display fields along with the id
|
|
82
|
+
# (location_id wins when both are given). On a multi-post thread
|
|
83
|
+
# (thread_parts) the tag is applied to part 1, and the Post's "threads"
|
|
84
|
+
# block echoes a "location" object when set. Threads location tagging
|
|
85
|
+
# is currently rolling out: until Meta approves the permissions it is
|
|
86
|
+
# disabled on production and create/update/publish return a 400 (also a
|
|
87
|
+
# 400 validation_error asking you to reconnect Threads when the
|
|
88
|
+
# connection lacks the threads_location_tagging permission).
|
|
70
89
|
def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
|
|
71
90
|
media_urls: nil, type: nil, source: nil, link_url: nil,
|
|
72
91
|
link_title: nil, link_description: nil, link_thumbnail_url: nil,
|
|
@@ -75,7 +94,8 @@ module OmniSocials
|
|
|
75
94
|
hashtag_platforms: nil, pinterest: nil, youtube: nil,
|
|
76
95
|
instagram: nil, facebook: nil, linkedin: nil,
|
|
77
96
|
linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
|
|
78
|
-
mastodon: nil,
|
|
97
|
+
mastodon: nil, threads: nil, google_business: nil,
|
|
98
|
+
linkedin_poll: nil, approval_workflow_id: nil)
|
|
79
99
|
body = create_body(
|
|
80
100
|
content: content, channels: channels, scheduled_at: scheduled_at,
|
|
81
101
|
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
@@ -87,8 +107,9 @@ module OmniSocials
|
|
|
87
107
|
hashtag_platforms: hashtag_platforms, pinterest: pinterest,
|
|
88
108
|
youtube: youtube, instagram: instagram, facebook: facebook,
|
|
89
109
|
linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
|
|
90
|
-
x: x, bluesky: bluesky, mastodon: mastodon,
|
|
91
|
-
google_business: google_business, linkedin_poll: linkedin_poll
|
|
110
|
+
x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
|
|
111
|
+
google_business: google_business, linkedin_poll: linkedin_poll,
|
|
112
|
+
approval_workflow_id: approval_workflow_id
|
|
92
113
|
)
|
|
93
114
|
@client.request("POST", "/posts/create", json: body)
|
|
94
115
|
end
|
|
@@ -106,7 +127,8 @@ module OmniSocials
|
|
|
106
127
|
pinterest: nil, youtube: nil, instagram: nil,
|
|
107
128
|
facebook: nil, linkedin: nil, linkedin_page: nil,
|
|
108
129
|
tiktok: nil, x: nil, bluesky: nil, mastodon: nil,
|
|
109
|
-
|
|
130
|
+
threads: nil, google_business: nil,
|
|
131
|
+
linkedin_poll: nil)
|
|
110
132
|
body = create_body(
|
|
111
133
|
content: content, channels: channels, scheduled_at: nil,
|
|
112
134
|
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
@@ -118,7 +140,7 @@ module OmniSocials
|
|
|
118
140
|
hashtag_platforms: hashtag_platforms, pinterest: pinterest,
|
|
119
141
|
youtube: youtube, instagram: instagram, facebook: facebook,
|
|
120
142
|
linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
|
|
121
|
-
x: x, bluesky: bluesky, mastodon: mastodon,
|
|
143
|
+
x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
|
|
122
144
|
google_business: google_business, linkedin_poll: linkedin_poll
|
|
123
145
|
)
|
|
124
146
|
@client.request("POST", "/posts/create-and-publish", json: body)
|
|
@@ -128,8 +150,9 @@ module OmniSocials
|
|
|
128
150
|
#
|
|
129
151
|
# Only top-level nils are dropped from the body, so passing e.g.
|
|
130
152
|
# x: { "thread_parts" => nil } still clears an X thread (reverts the
|
|
131
|
-
# post to single-tweet mode). The same applies to bluesky
|
|
132
|
-
# thread parts
|
|
153
|
+
# post to single-tweet mode). The same applies to bluesky, mastodon
|
|
154
|
+
# and threads thread parts, and to a Threads location tag:
|
|
155
|
+
# threads: { "location_id" => nil } (or "location" => nil) clears it.
|
|
133
156
|
#
|
|
134
157
|
# See #create for the 402 "x_credits_insufficient" credit gate that
|
|
135
158
|
# can also refuse an update to a scheduled X link post.
|
|
@@ -138,7 +161,8 @@ module OmniSocials
|
|
|
138
161
|
collaborators: nil, user_tags: nil, pinterest: nil,
|
|
139
162
|
youtube: nil, instagram: nil, facebook: nil, linkedin: nil,
|
|
140
163
|
linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
|
|
141
|
-
mastodon: nil,
|
|
164
|
+
mastodon: nil, threads: nil, google_business: nil,
|
|
165
|
+
linkedin_poll: nil)
|
|
142
166
|
body = Internal.drop_nil(
|
|
143
167
|
{
|
|
144
168
|
"content" => content,
|
|
@@ -160,6 +184,7 @@ module OmniSocials
|
|
|
160
184
|
"x" => x,
|
|
161
185
|
"bluesky" => bluesky,
|
|
162
186
|
"mastodon" => mastodon,
|
|
187
|
+
"threads" => threads,
|
|
163
188
|
"google_business" => google_business,
|
|
164
189
|
"linkedin_poll" => linkedin_poll
|
|
165
190
|
}
|
|
@@ -167,7 +192,7 @@ module OmniSocials
|
|
|
167
192
|
@client.request("PATCH", "/posts/#{post_id}", json: body)
|
|
168
193
|
end
|
|
169
194
|
|
|
170
|
-
# DELETE /posts/{id} -
|
|
195
|
+
# DELETE /posts/{id} - remove a post from OmniSocials (the live post stays on the platform). Returns nil (204).
|
|
171
196
|
def delete(post_id)
|
|
172
197
|
@client.request("DELETE", "/posts/#{post_id}")
|
|
173
198
|
end
|
|
@@ -190,6 +215,29 @@ module OmniSocials
|
|
|
190
215
|
@client.request("POST", "/posts/#{post_id}/retry")
|
|
191
216
|
end
|
|
192
217
|
|
|
218
|
+
# POST /posts/{id}/approve - approve the current step of a post's
|
|
219
|
+
# approval workflow, on behalf of the user who owns this API key. That
|
|
220
|
+
# user must be a listed approver for the workflow's CURRENT step -
|
|
221
|
+
# steps approve in order, so an approver on a later step gets a 403
|
|
222
|
+
# "forbidden" error until earlier steps clear. Only works on a post
|
|
223
|
+
# with approval_status "pending". If this is the last step, the post
|
|
224
|
+
# finalizes immediately ("scheduled" or "posting"); otherwise it stays
|
|
225
|
+
# "in_approval" and the next step's approvers are notified.
|
|
226
|
+
def approve(post_id)
|
|
227
|
+
@client.request("POST", "/posts/#{post_id}/approve")
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# POST /posts/{id}/reject - reject a post's approval workflow, on
|
|
231
|
+
# behalf of the user who owns this API key. Same approver requirement
|
|
232
|
+
# as #approve. Unlike approval, a rejection stops the WHOLE workflow
|
|
233
|
+
# immediately (not just the current step) - the post's status becomes
|
|
234
|
+
# "rejected". `comment` is optional and, when given, is shown to the
|
|
235
|
+
# requester and other approvers in the post's review thread.
|
|
236
|
+
def reject(post_id, comment: nil)
|
|
237
|
+
body = comment ? { comment: comment } : nil
|
|
238
|
+
@client.request("POST", "/posts/#{post_id}/reject", json: body)
|
|
239
|
+
end
|
|
240
|
+
|
|
193
241
|
private
|
|
194
242
|
|
|
195
243
|
def create_body(content:, channels:, scheduled_at:, media_ids:,
|
|
@@ -199,7 +247,8 @@ module OmniSocials
|
|
|
199
247
|
hashtag_set_id:, hashtag_placement:, hashtag_platforms:,
|
|
200
248
|
pinterest:, youtube:, instagram:, facebook:, linkedin:,
|
|
201
249
|
linkedin_page:, tiktok:, x:, bluesky:, mastodon:,
|
|
202
|
-
google_business:, linkedin_poll
|
|
250
|
+
threads:, google_business:, linkedin_poll:,
|
|
251
|
+
approval_workflow_id: nil)
|
|
203
252
|
Internal.drop_nil(
|
|
204
253
|
{
|
|
205
254
|
"content" => content,
|
|
@@ -230,8 +279,10 @@ module OmniSocials
|
|
|
230
279
|
"x" => x,
|
|
231
280
|
"bluesky" => bluesky,
|
|
232
281
|
"mastodon" => mastodon,
|
|
282
|
+
"threads" => threads,
|
|
233
283
|
"google_business" => google_business,
|
|
234
|
-
"linkedin_poll" => linkedin_poll
|
|
284
|
+
"linkedin_poll" => linkedin_poll,
|
|
285
|
+
"approval_workflow_id" => approval_workflow_id
|
|
235
286
|
}
|
|
236
287
|
)
|
|
237
288
|
end
|
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.6.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-09-17 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
|