omnisocials 0.1.0 → 0.3.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 +133 -1
- data/lib/omnisocials/client.rb +5 -2
- data/lib/omnisocials/resources/audio.rb +22 -0
- data/lib/omnisocials/resources/hashtag_sets.rb +48 -0
- data/lib/omnisocials/resources/inbox.rb +97 -0
- data/lib/omnisocials/resources/posts.rb +87 -22
- data/lib/omnisocials/version.rb +1 -1
- data/lib/omnisocials.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0200cd0b7aa77ce95c037b0f5fd93a9e80b7bfdc589194b53c80084bb560b165
|
|
4
|
+
data.tar.gz: 37e48405dd0871201467442b940d2bc1dabd8057deddd81f903e25250bdb5cbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 886f262e3a7eb8c2164f75845e8c67c3e5d86c2869974ffb290b69d8ebeb29b4f29cba11130d1722152236c3b865d9651194709fef35a24bc15b9eba4fe798a9
|
|
7
|
+
data.tar.gz: '078aba4be97a9fbcceef92fc3587059ea6e7cef7d760da25949bbc5eff37cc1ea3ff4fb59d47b8b5ec2ad12b532b5e7f4dace15572df90b8a42cffb57e8e75e2'
|
data/README.md
CHANGED
|
@@ -65,6 +65,24 @@ post = client.posts.create_and_publish(
|
|
|
65
65
|
)
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
### Per-media alt text
|
|
69
|
+
|
|
70
|
+
Every `media_urls` / `media_ids` entry accepts either a plain String or a Hash with an `alt` accessibility description (max 1500 chars). Alt text is delivered to Mastodon (media description), Bluesky (embed alt), X (photos and GIFs), Pinterest (pin alt text), Instagram (images), and LinkedIn (images). Strings and Hashes can be mixed, and the same shape works in per-platform Hashes and `thread_parts` media.
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
post = client.posts.create(
|
|
74
|
+
content: "Sunrise over the harbor",
|
|
75
|
+
channels: ["mastodon", "bluesky"],
|
|
76
|
+
scheduled_at: "2026-08-01T09:00:00Z",
|
|
77
|
+
media_urls: [
|
|
78
|
+
{
|
|
79
|
+
"url" => "https://example.com/harbor.jpg",
|
|
80
|
+
"alt" => "A small sailboat crossing a calm harbor at sunrise, sky in deep orange"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
68
86
|
### Post to specific platforms with platform options
|
|
69
87
|
|
|
70
88
|
`content` can be a per-platform Hash (with `default` as the fallback), and each platform has its own options Hash:
|
|
@@ -107,6 +125,44 @@ post = client.posts.create(
|
|
|
107
125
|
|
|
108
126
|
On update, passing `x: { "thread_parts" => nil }` clears the thread and reverts the post to single-tweet mode. Only top-level `nil` values are dropped from request bodies, so nested `nil` values like this one are sent as JSON `null`.
|
|
109
127
|
|
|
128
|
+
### X link posts use credits
|
|
129
|
+
|
|
130
|
+
X bills API posts whose text contains a URL at a premium, and OmniSocials passes that fee through as prepaid credits (20 credits per URL-containing tweet; threads billed per part with a link). When a create targets X and the text contains a URL, the response Hash includes a top-level `"warnings"` array (a sibling of `"data"`):
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
res = client.posts.create(
|
|
134
|
+
content: "Read the full story: https://example.com/post",
|
|
135
|
+
channels: ["x"]
|
|
136
|
+
)
|
|
137
|
+
(res["warnings"] || []).each do |warning|
|
|
138
|
+
if warning["code"] == "x_url_post_credits"
|
|
139
|
+
puts "#{warning["credits_required"]} credits (balance: #{warning["credits_balance"]})"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
From `enforce_from` (2026-08-14) the balance is checked at publish time, but credits are only deducted after the post successfully publishes (a failed publish is never charged). If the balance can't cover it, only the X target fails (other platforms publish normally); top up in the dashboard under Settings -> Organisation -> Billing -> Credits, then call `posts.retry`. Posts without links, analytics, and media on X stay free. There is no API endpoint for credits — they are managed in the dashboard.
|
|
145
|
+
|
|
146
|
+
Separately, from 2026-08-14 `posts.create` / `posts.update` / `posts.publish` on X can refuse the request itself, up front, with a `402` and error code `x_credits_insufficient` (`error.details` carries `credits_required`, `credits_balance`, and `credits_reserved`) when reserving this post's cost would push the company's total reserved credits past its balance:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
begin
|
|
150
|
+
client.posts.create(
|
|
151
|
+
content: "Read the full story: https://example.com/post",
|
|
152
|
+
channels: ["x"],
|
|
153
|
+
scheduled_at: "2026-08-20T09:00:00Z"
|
|
154
|
+
)
|
|
155
|
+
rescue OmniSocials::APIError => e
|
|
156
|
+
if e.code == "x_credits_insufficient"
|
|
157
|
+
puts "Not enough reserved credits: #{e.body["error"]["details"]}"
|
|
158
|
+
else
|
|
159
|
+
raise
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Drafts are never gated, and posts scheduled to publish before 2026-08-14 are never gated either.
|
|
165
|
+
|
|
110
166
|
### Other post operations
|
|
111
167
|
|
|
112
168
|
```ruby
|
|
@@ -114,13 +170,18 @@ posts = client.posts.list(status: "scheduled", limit: 50, offset: 0)
|
|
|
114
170
|
post = client.posts.get("123")
|
|
115
171
|
client.posts.update("123", scheduled_at: "2026-08-02T10:00:00Z")
|
|
116
172
|
client.posts.publish("123") # publish a draft or scheduled post now
|
|
173
|
+
client.posts.retry("123") # retry only the failed platforms of a failed/warning post
|
|
117
174
|
client.posts.delete("123") # returns nil (204)
|
|
118
175
|
|
|
119
176
|
# Recent posts fetched live from the connected platforms (including content
|
|
120
|
-
# published outside OmniSocials). Requires the analytics:read scope.
|
|
177
|
+
# published outside OmniSocials). Requires the analytics:read scope. Video
|
|
178
|
+
# records include duration_seconds (whole seconds; TikTok and YouTube today,
|
|
179
|
+
# null for images and platforms that don't expose it).
|
|
121
180
|
recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tiktok"])
|
|
122
181
|
```
|
|
123
182
|
|
|
183
|
+
`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
|
+
|
|
124
185
|
## Media
|
|
125
186
|
|
|
126
187
|
### Upload from a URL (recommended, up to 1GB)
|
|
@@ -190,6 +251,36 @@ client.folders.update(sub["data"]["id"], parent_id: nil) # move to top level
|
|
|
190
251
|
client.folders.delete(folder["data"]["id"]) # files move to root, subfolders move up
|
|
191
252
|
```
|
|
192
253
|
|
|
254
|
+
## Hashtag Sets
|
|
255
|
+
|
|
256
|
+
Save reusable hashtag groups and apply them to posts at create time. Uses the `posts:read` / `posts:write` scopes.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
set = client.hashtag_sets.create(
|
|
260
|
+
name: "Launch",
|
|
261
|
+
hashtags: ["saas", "buildinpublic", "startup"] # or one string: "#saas #buildinpublic #startup"
|
|
262
|
+
)
|
|
263
|
+
puts set["data"]["preview"] # "#saas #buildinpublic #startup"
|
|
264
|
+
|
|
265
|
+
client.hashtag_sets.list
|
|
266
|
+
client.hashtag_sets.get(set["data"]["id"])
|
|
267
|
+
client.hashtag_sets.update(set["data"]["id"], hashtags: ["saas", "founder"]) # replaces the full list
|
|
268
|
+
client.hashtag_sets.delete(set["data"]["id"]) # returns nil (204)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Apply a set when creating a post with `hashtag_set` (the set name, case-insensitive) or `hashtag_set_id`. The set is applied once at create time and tags already in the caption are skipped. `hashtag_placement` is `"caption_append"` (default) or `"first_comment"`, and `hashtag_platforms` restricts the hashtags to a subset of the post's channels. Instagram's 30-hashtag cap returns error code `hashtag_limit_exceeded`.
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
client.posts.create(
|
|
275
|
+
content: "Launch day!",
|
|
276
|
+
channels: ["instagram", "x"],
|
|
277
|
+
scheduled_at: "2026-08-01T09:00:00Z",
|
|
278
|
+
hashtag_set: "Launch",
|
|
279
|
+
hashtag_placement: "first_comment",
|
|
280
|
+
hashtag_platforms: ["instagram"]
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
193
284
|
## Accounts
|
|
194
285
|
|
|
195
286
|
```ruby
|
|
@@ -239,6 +330,47 @@ client.posts.create(
|
|
|
239
330
|
)
|
|
240
331
|
```
|
|
241
332
|
|
|
333
|
+
## Inbox
|
|
334
|
+
|
|
335
|
+
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`.
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
conversations = client.inbox.list_conversations(platform: "instagram", unread: true, limit: 20)
|
|
339
|
+
conversations["data"].each do |conversation|
|
|
340
|
+
puts "#{conversation["platform"]}: #{conversation["preview"]}"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
conversation_id = conversations["data"][0]["id"]
|
|
344
|
+
messages = client.inbox.get_messages(conversation_id)
|
|
345
|
+
messages["data"].each do |message|
|
|
346
|
+
puts "#{message["direction"]}: #{message["text"]}" # direction is "incoming" or "outgoing"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
client.inbox.mark_read(conversation_id)
|
|
350
|
+
client.inbox.reply(conversation_id, text: "Thanks for reaching out!")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, or `"x"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. TikTok replies are comments only and 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"`).
|
|
354
|
+
|
|
355
|
+
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
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
begin
|
|
359
|
+
client.inbox.reply(conversation_id, text: "On it, thanks!")
|
|
360
|
+
rescue OmniSocials::APIError => e
|
|
361
|
+
case e.code
|
|
362
|
+
when "insufficient_credits"
|
|
363
|
+
puts "Not enough credits to send this reply (need 2)."
|
|
364
|
+
when "x_inbox_suspended"
|
|
365
|
+
puts "X inbox is suspended - top up and re-enable it in the dashboard."
|
|
366
|
+
else
|
|
367
|
+
raise
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`x_inbox_suspended` fires once a workspace's X inbox has auto-suspended after its credit balance hit zero; topping up and re-enabling it in the dashboard resumes delivery, but DMs that arrived while suspended are not recovered.
|
|
373
|
+
|
|
242
374
|
## Webhooks
|
|
243
375
|
|
|
244
376
|
Subscribe to `post.scheduled`, `post.published`, and `post.failed` events:
|
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, :
|
|
63
|
-
:webhooks
|
|
62
|
+
:posts, :media, :folders, :hashtag_sets, :accounts, :analytics,
|
|
63
|
+
: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
|
|
@@ -88,9 +88,12 @@ module OmniSocials
|
|
|
88
88
|
@posts = Resources::Posts.new(self)
|
|
89
89
|
@media = Resources::Media.new(self)
|
|
90
90
|
@folders = Resources::Folders.new(self)
|
|
91
|
+
@hashtag_sets = Resources::HashtagSets.new(self)
|
|
91
92
|
@accounts = Resources::Accounts.new(self)
|
|
92
93
|
@analytics = Resources::Analytics.new(self)
|
|
94
|
+
@audio = Resources::Audio.new(self)
|
|
93
95
|
@locations = Resources::Locations.new(self)
|
|
96
|
+
@inbox = Resources::Inbox.new(self)
|
|
94
97
|
@webhooks = Resources::Webhooks.new(self)
|
|
95
98
|
end
|
|
96
99
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Audio resource: Instagram Reels audio (Meta's licensed catalog).
|
|
6
|
+
class Audio
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /audio/search?q=&type= - search Meta's licensed audio catalog for
|
|
12
|
+
# Instagram Reels. Omit query for trending audio; type is "music"
|
|
13
|
+
# (default) or "original_sound". Use a result's audio_id as
|
|
14
|
+
# instagram.audio_id on a reel post (with optional
|
|
15
|
+
# instagram.audio_volume / instagram.video_volume, integers 0-100).
|
|
16
|
+
# preview_url is a temporary URL (~1.5 days); never persist it.
|
|
17
|
+
def search(query: nil, type: nil)
|
|
18
|
+
@client.request("GET", "/audio/search", query: { "q" => query, "type" => type })
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Hashtag sets resource: saved, reusable hashtag groups applied to posts
|
|
6
|
+
# at create time (via hashtag_set / hashtag_set_id on posts.create).
|
|
7
|
+
class HashtagSets
|
|
8
|
+
def initialize(client)
|
|
9
|
+
@client = client
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# GET /hashtag-sets - list the workspace's saved hashtag sets.
|
|
13
|
+
def list
|
|
14
|
+
@client.request("GET", "/hashtag-sets")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET /hashtag-sets/{id} - fetch a single hashtag set.
|
|
18
|
+
def get(hashtag_set_id)
|
|
19
|
+
@client.request("GET", "/hashtag-sets/#{hashtag_set_id}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# POST /hashtag-sets - create a hashtag set.
|
|
23
|
+
#
|
|
24
|
+
# `hashtags` is an Array of tags, or a single String of tags. Apply the
|
|
25
|
+
# set on posts.create via hashtag_set (name, case-insensitive) or
|
|
26
|
+
# hashtag_set_id.
|
|
27
|
+
def create(name:, hashtags:)
|
|
28
|
+
@client.request(
|
|
29
|
+
"POST", "/hashtag-sets",
|
|
30
|
+
json: { "name" => name, "hashtags" => hashtags }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# PATCH /hashtag-sets/{id} - rename and/or replace the tags.
|
|
35
|
+
#
|
|
36
|
+
# `hashtags` replaces the FULL list.
|
|
37
|
+
def update(hashtag_set_id, name: nil, hashtags: nil)
|
|
38
|
+
body = Internal.drop_nil({ "name" => name, "hashtags" => hashtags })
|
|
39
|
+
@client.request("PATCH", "/hashtag-sets/#{hashtag_set_id}", json: body)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# DELETE /hashtag-sets/{id} - delete a hashtag set. Returns nil (204).
|
|
43
|
+
def delete(hashtag_set_id)
|
|
44
|
+
@client.request("DELETE", "/hashtag-sets/#{hashtag_set_id}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module OmniSocials
|
|
6
|
+
module Resources
|
|
7
|
+
# Inbox resource: social inbox conversations (DMs, comments, mentions)
|
|
8
|
+
# across connected platforms, plus reading and replying to them.
|
|
9
|
+
#
|
|
10
|
+
# The list endpoints use CURSOR pagination (unlike the offset pagination
|
|
11
|
+
# elsewhere): each response carries pagination["next_cursor"],
|
|
12
|
+
# pagination["has_more"], and pagination["limit"]. Page on by passing the
|
|
13
|
+
# previous response's next_cursor as `cursor` while has_more is true.
|
|
14
|
+
#
|
|
15
|
+
# Conversation ids are URL-encoded for you, so pass them exactly as
|
|
16
|
+
# returned - LinkedIn ids contain ":" and "()" (e.g.
|
|
17
|
+
# "linkedin_comment_urn:li:activity:123").
|
|
18
|
+
class Inbox
|
|
19
|
+
def initialize(client)
|
|
20
|
+
@client = client
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GET /inbox/conversations - list conversations, newest activity first.
|
|
24
|
+
#
|
|
25
|
+
# All filters are optional: platform ("instagram", "facebook",
|
|
26
|
+
# "linkedin", "tiktok", "x"), type ("dm", "comment", "mention"),
|
|
27
|
+
# unread (only conversations with unread messages), limit (1-100), and
|
|
28
|
+
# cursor (an opaque cursor from a previous response's
|
|
29
|
+
# pagination["next_cursor"]).
|
|
30
|
+
def list_conversations(platform: nil, type: nil, unread: nil, limit: nil, cursor: nil)
|
|
31
|
+
@client.request(
|
|
32
|
+
"GET", "/inbox/conversations",
|
|
33
|
+
query: {
|
|
34
|
+
"platform" => platform,
|
|
35
|
+
"type" => type,
|
|
36
|
+
"unread" => unread,
|
|
37
|
+
"limit" => limit,
|
|
38
|
+
"cursor" => cursor
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# GET /inbox/conversations/{id}/messages - full message thread for one
|
|
44
|
+
# conversation, newest first. Cursor-paginated via `limit` / `cursor`.
|
|
45
|
+
def get_messages(conversation_id, limit: nil, cursor: nil)
|
|
46
|
+
@client.request(
|
|
47
|
+
"GET", "/inbox/conversations/#{encode_id(conversation_id)}/messages",
|
|
48
|
+
query: { "limit" => limit, "cursor" => cursor }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /inbox/conversations/{id}/read - mark every message in the
|
|
53
|
+
# conversation as read. Returns the count of messages newly marked read.
|
|
54
|
+
def mark_read(conversation_id)
|
|
55
|
+
@client.request("POST", "/inbox/conversations/#{encode_id(conversation_id)}/read")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /inbox/conversations/{id}/reply - send a reply into the
|
|
59
|
+
# conversation (a DM message, or a reply to the comment/mention).
|
|
60
|
+
#
|
|
61
|
+
# `text` is required. Optionally attach a single media asset by public
|
|
62
|
+
# URL with `attachment_url` plus `attachment_type` ("image", "video",
|
|
63
|
+
# "audio", or "file"). Returns the created outgoing message.
|
|
64
|
+
#
|
|
65
|
+
# Replying to an X DM costs 2 prepaid credits, debited from the
|
|
66
|
+
# company balance before the send and automatically refunded if the
|
|
67
|
+
# send fails. Two 402 error codes are specific to this call:
|
|
68
|
+
# "insufficient_credits" when the balance can't cover the 2 credits,
|
|
69
|
+
# and "x_inbox_suspended" when the workspace's X inbox was
|
|
70
|
+
# auto-suspended after the balance hit zero (top up and re-enable it
|
|
71
|
+
# in the dashboard to resume; DMs that arrived while suspended are
|
|
72
|
+
# not recovered).
|
|
73
|
+
def reply(conversation_id, text:, attachment_url: nil, attachment_type: nil)
|
|
74
|
+
body = Internal.drop_nil(
|
|
75
|
+
{
|
|
76
|
+
"text" => text,
|
|
77
|
+
"attachment_url" => attachment_url,
|
|
78
|
+
"attachment_type" => attachment_type
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
@client.request(
|
|
82
|
+
"POST", "/inbox/conversations/#{encode_id(conversation_id)}/reply",
|
|
83
|
+
json: body
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# URL-encode a conversation id for use in a path segment. LinkedIn ids
|
|
90
|
+
# contain ":" and "()", so they must be escaped; spaces become %20
|
|
91
|
+
# (a path segment treats "+" literally, unlike a query string).
|
|
92
|
+
def encode_id(conversation_id)
|
|
93
|
+
CGI.escape(conversation_id.to_s).gsub("+", "%20")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -6,6 +6,13 @@ module OmniSocials
|
|
|
6
6
|
#
|
|
7
7
|
# `content` is a plain String, or a per-platform Hash with a "default" key.
|
|
8
8
|
# `media_ids` / `media_urls` are a flat Array, or a per-platform Hash.
|
|
9
|
+
# Each entry is a plain String, or a Hash with an "alt" accessibility
|
|
10
|
+
# description (max 1500 chars): `{ "url" => "https://...", "alt" => "..." }`
|
|
11
|
+
# for media_urls, `{ "id" => "...", "alt" => "..." }` for media_ids. Alt
|
|
12
|
+
# text is delivered to Mastodon (media description), Bluesky (embed alt),
|
|
13
|
+
# X (photos/GIFs), Pinterest (pin alt text), Instagram (images), and
|
|
14
|
+
# LinkedIn (images); the same entry shape works inside x/bluesky/mastodon
|
|
15
|
+
# `thread_parts` media.
|
|
9
16
|
class Posts
|
|
10
17
|
def initialize(client)
|
|
11
18
|
@client = client
|
|
@@ -36,47 +43,81 @@ module OmniSocials
|
|
|
36
43
|
|
|
37
44
|
# POST /posts/create - create a post (draft, or scheduled when
|
|
38
45
|
# scheduled_at is set).
|
|
46
|
+
#
|
|
47
|
+
# hashtag_set (set name, case-insensitive) or hashtag_set_id applies a
|
|
48
|
+
# saved hashtag set once at create time; tags already in a caption are
|
|
49
|
+
# skipped; Instagram's 30-hashtag cap returns error code
|
|
50
|
+
# hashtag_limit_exceeded. hashtag_placement is "caption_append"
|
|
51
|
+
# (default) or "first_comment"; hashtag_platforms restricts the tags to
|
|
52
|
+
# a subset of channels.
|
|
53
|
+
#
|
|
54
|
+
# When the post targets X and its text (or any thread part) contains a
|
|
55
|
+
# URL, the response includes a top-level "warnings" array (sibling of
|
|
56
|
+
# "data") with a "x_url_post_credits" entry carrying credits_required
|
|
57
|
+
# and credits_balance: X's link-post fee is passed through as prepaid
|
|
58
|
+
# credits, debited at publish time (from 2026-08-14). Credits are
|
|
59
|
+
# managed in the dashboard, not the API.
|
|
60
|
+
#
|
|
61
|
+
# Separately, from 2026-08-14 this call (and #update / #publish) can
|
|
62
|
+
# refuse an X link post up front with a 402 and error code
|
|
63
|
+
# "x_credits_insufficient" (details: credits_required, credits_balance,
|
|
64
|
+
# credits_reserved) when reserving this post's cost would push the
|
|
65
|
+
# company's total reserved credits past its balance. Drafts are never
|
|
66
|
+
# gated, and posts scheduled to publish before 2026-08-14 are never
|
|
67
|
+
# gated either.
|
|
39
68
|
def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
|
|
40
69
|
media_urls: nil, type: nil, source: nil, link_url: nil,
|
|
41
70
|
link_title: nil, link_description: nil, link_thumbnail_url: nil,
|
|
42
71
|
location_id: nil, collaborators: nil, user_tags: nil,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
72
|
+
hashtag_set: nil, hashtag_set_id: nil, hashtag_placement: nil,
|
|
73
|
+
hashtag_platforms: nil, pinterest: nil, youtube: nil,
|
|
74
|
+
instagram: nil, facebook: nil, linkedin: nil,
|
|
75
|
+
linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
|
|
76
|
+
mastodon: nil, google_business: nil, linkedin_poll: nil)
|
|
46
77
|
body = create_body(
|
|
47
78
|
content: content, channels: channels, scheduled_at: scheduled_at,
|
|
48
79
|
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
49
80
|
source: source, link_url: link_url, link_title: link_title,
|
|
50
81
|
link_description: link_description, link_thumbnail_url: link_thumbnail_url,
|
|
51
82
|
location_id: location_id, collaborators: collaborators,
|
|
52
|
-
user_tags: user_tags,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
user_tags: user_tags, hashtag_set: hashtag_set,
|
|
84
|
+
hashtag_set_id: hashtag_set_id, hashtag_placement: hashtag_placement,
|
|
85
|
+
hashtag_platforms: hashtag_platforms, pinterest: pinterest,
|
|
86
|
+
youtube: youtube, instagram: instagram, facebook: facebook,
|
|
87
|
+
linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
|
|
88
|
+
x: x, bluesky: bluesky, mastodon: mastodon,
|
|
89
|
+
google_business: google_business, linkedin_poll: linkedin_poll
|
|
56
90
|
)
|
|
57
91
|
@client.request("POST", "/posts/create", json: body)
|
|
58
92
|
end
|
|
59
93
|
|
|
60
94
|
# POST /posts/create-and-publish - create and publish immediately.
|
|
95
|
+
# See #create for the "warnings" array and the 402
|
|
96
|
+
# "x_credits_insufficient" credit gate on X link posts.
|
|
61
97
|
def create_and_publish(content:, channels: nil, media_ids: nil,
|
|
62
98
|
media_urls: nil, type: nil, source: nil,
|
|
63
99
|
link_url: nil, link_title: nil, link_description: nil,
|
|
64
100
|
link_thumbnail_url: nil, location_id: nil,
|
|
65
|
-
collaborators: nil, user_tags: nil,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
collaborators: nil, user_tags: nil,
|
|
102
|
+
hashtag_set: nil, hashtag_set_id: nil,
|
|
103
|
+
hashtag_placement: nil, hashtag_platforms: nil,
|
|
104
|
+
pinterest: nil, youtube: nil, instagram: nil,
|
|
105
|
+
facebook: nil, linkedin: nil, linkedin_page: nil,
|
|
106
|
+
tiktok: nil, x: nil, bluesky: nil, mastodon: nil,
|
|
107
|
+
google_business: nil, linkedin_poll: nil)
|
|
70
108
|
body = create_body(
|
|
71
109
|
content: content, channels: channels, scheduled_at: nil,
|
|
72
110
|
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
73
111
|
source: source, link_url: link_url, link_title: link_title,
|
|
74
112
|
link_description: link_description, link_thumbnail_url: link_thumbnail_url,
|
|
75
113
|
location_id: location_id, collaborators: collaborators,
|
|
76
|
-
user_tags: user_tags,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
user_tags: user_tags, hashtag_set: hashtag_set,
|
|
115
|
+
hashtag_set_id: hashtag_set_id, hashtag_placement: hashtag_placement,
|
|
116
|
+
hashtag_platforms: hashtag_platforms, pinterest: pinterest,
|
|
117
|
+
youtube: youtube, instagram: instagram, facebook: facebook,
|
|
118
|
+
linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
|
|
119
|
+
x: x, bluesky: bluesky, mastodon: mastodon,
|
|
120
|
+
google_business: google_business, linkedin_poll: linkedin_poll
|
|
80
121
|
)
|
|
81
122
|
@client.request("POST", "/posts/create-and-publish", json: body)
|
|
82
123
|
end
|
|
@@ -87,12 +128,15 @@ module OmniSocials
|
|
|
87
128
|
# x: { "thread_parts" => nil } still clears an X thread (reverts the
|
|
88
129
|
# post to single-tweet mode). The same applies to bluesky and mastodon
|
|
89
130
|
# thread parts.
|
|
131
|
+
#
|
|
132
|
+
# See #create for the 402 "x_credits_insufficient" credit gate that
|
|
133
|
+
# can also refuse an update to a scheduled X link post.
|
|
90
134
|
def update(post_id, content: nil, scheduled_at: nil, channels: nil,
|
|
91
135
|
media_ids: nil, media_urls: nil, type: nil, location_id: nil,
|
|
92
136
|
collaborators: nil, user_tags: nil, pinterest: nil,
|
|
93
137
|
youtube: nil, instagram: nil, facebook: nil, linkedin: nil,
|
|
94
138
|
linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
|
|
95
|
-
mastodon: nil, google_business: nil)
|
|
139
|
+
mastodon: nil, google_business: nil, linkedin_poll: nil)
|
|
96
140
|
body = Internal.drop_nil(
|
|
97
141
|
{
|
|
98
142
|
"content" => content,
|
|
@@ -114,7 +158,8 @@ module OmniSocials
|
|
|
114
158
|
"x" => x,
|
|
115
159
|
"bluesky" => bluesky,
|
|
116
160
|
"mastodon" => mastodon,
|
|
117
|
-
"google_business" => google_business
|
|
161
|
+
"google_business" => google_business,
|
|
162
|
+
"linkedin_poll" => linkedin_poll
|
|
118
163
|
}
|
|
119
164
|
)
|
|
120
165
|
@client.request("PATCH", "/posts/#{post_id}", json: body)
|
|
@@ -126,18 +171,33 @@ module OmniSocials
|
|
|
126
171
|
end
|
|
127
172
|
|
|
128
173
|
# POST /posts/{id}/publish - publish a draft or scheduled post now.
|
|
174
|
+
# See #create for the 402 "x_credits_insufficient" credit gate that
|
|
175
|
+
# can also refuse publishing a scheduled X link post.
|
|
129
176
|
def publish(post_id)
|
|
130
177
|
@client.request("POST", "/posts/#{post_id}/publish")
|
|
131
178
|
end
|
|
132
179
|
|
|
180
|
+
# POST /posts/{id}/retry - retry the failed platforms of a "failed" or
|
|
181
|
+
# "warning" (partially failed) post, on the same post.
|
|
182
|
+
#
|
|
183
|
+
# Only the platforms that failed are re-published; platforms that
|
|
184
|
+
# already succeeded are never posted again. Asynchronous: a 200 means
|
|
185
|
+
# the retry is queued - poll `get` for the outcome. Max 3 retries per
|
|
186
|
+
# platform.
|
|
187
|
+
def retry(post_id)
|
|
188
|
+
@client.request("POST", "/posts/#{post_id}/retry")
|
|
189
|
+
end
|
|
190
|
+
|
|
133
191
|
private
|
|
134
192
|
|
|
135
193
|
def create_body(content:, channels:, scheduled_at:, media_ids:,
|
|
136
194
|
media_urls:, type:, source:, link_url:, link_title:,
|
|
137
195
|
link_description:, link_thumbnail_url:, location_id:,
|
|
138
|
-
collaborators:, user_tags:,
|
|
139
|
-
|
|
140
|
-
|
|
196
|
+
collaborators:, user_tags:, hashtag_set:,
|
|
197
|
+
hashtag_set_id:, hashtag_placement:, hashtag_platforms:,
|
|
198
|
+
pinterest:, youtube:, instagram:, facebook:, linkedin:,
|
|
199
|
+
linkedin_page:, tiktok:, x:, bluesky:, mastodon:,
|
|
200
|
+
google_business:, linkedin_poll:)
|
|
141
201
|
Internal.drop_nil(
|
|
142
202
|
{
|
|
143
203
|
"content" => content,
|
|
@@ -154,6 +214,10 @@ module OmniSocials
|
|
|
154
214
|
"location_id" => location_id,
|
|
155
215
|
"collaborators" => collaborators,
|
|
156
216
|
"user_tags" => user_tags,
|
|
217
|
+
"hashtag_set" => hashtag_set,
|
|
218
|
+
"hashtag_set_id" => hashtag_set_id,
|
|
219
|
+
"hashtag_placement" => hashtag_placement,
|
|
220
|
+
"hashtag_platforms" => hashtag_platforms,
|
|
157
221
|
"pinterest" => pinterest,
|
|
158
222
|
"youtube" => youtube,
|
|
159
223
|
"instagram" => instagram,
|
|
@@ -164,7 +228,8 @@ module OmniSocials
|
|
|
164
228
|
"x" => x,
|
|
165
229
|
"bluesky" => bluesky,
|
|
166
230
|
"mastodon" => mastodon,
|
|
167
|
-
"google_business" => google_business
|
|
231
|
+
"google_business" => google_business,
|
|
232
|
+
"linkedin_poll" => linkedin_poll
|
|
168
233
|
}
|
|
169
234
|
)
|
|
170
235
|
end
|
data/lib/omnisocials/version.rb
CHANGED
data/lib/omnisocials.rb
CHANGED
|
@@ -19,9 +19,12 @@ require_relative "omnisocials/webhooks"
|
|
|
19
19
|
require_relative "omnisocials/resources/posts"
|
|
20
20
|
require_relative "omnisocials/resources/media"
|
|
21
21
|
require_relative "omnisocials/resources/folders"
|
|
22
|
+
require_relative "omnisocials/resources/hashtag_sets"
|
|
22
23
|
require_relative "omnisocials/resources/accounts"
|
|
23
24
|
require_relative "omnisocials/resources/analytics"
|
|
25
|
+
require_relative "omnisocials/resources/audio"
|
|
24
26
|
require_relative "omnisocials/resources/locations"
|
|
27
|
+
require_relative "omnisocials/resources/inbox"
|
|
25
28
|
require_relative "omnisocials/resources/webhooks"
|
|
26
29
|
require_relative "omnisocials/client"
|
|
27
30
|
|
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.3.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-08-20 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,7 +27,10 @@ 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/audio.rb
|
|
30
31
|
- lib/omnisocials/resources/folders.rb
|
|
32
|
+
- lib/omnisocials/resources/hashtag_sets.rb
|
|
33
|
+
- lib/omnisocials/resources/inbox.rb
|
|
31
34
|
- lib/omnisocials/resources/locations.rb
|
|
32
35
|
- lib/omnisocials/resources/media.rb
|
|
33
36
|
- lib/omnisocials/resources/posts.rb
|