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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d8e63bac3e6cf2774234d627061b6ef9d2f2555329374da55bc37b2dc912593
4
- data.tar.gz: 1f432caac9857e978a539b9382aeb11390a5e02637c97725a84f5b7146f14206
3
+ metadata.gz: 0200cd0b7aa77ce95c037b0f5fd93a9e80b7bfdc589194b53c80084bb560b165
4
+ data.tar.gz: 37e48405dd0871201467442b940d2bc1dabd8057deddd81f903e25250bdb5cbc
5
5
  SHA512:
6
- metadata.gz: '079b0f69fe85f2317bae07bf58246d49a150d6a5afaed56aac9c4dc3111dfc6713ac250c12591f6f8428923e91003333377b004a6f64fe8eb786660b9fb75eca'
7
- data.tar.gz: 5cced0edb928d0b5600a94ca7a28a8c3a809bfc58eee18ef89683c665f04327aee7d215c7982af068144165718e87468b237e698b7d36a273013cb6503df206e
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:
@@ -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, :accounts, :analytics, :locations,
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
- pinterest: nil, youtube: nil, instagram: nil, facebook: nil,
44
- linkedin: nil, linkedin_page: nil, tiktok: nil, x: nil,
45
- bluesky: nil, mastodon: nil, google_business: nil)
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, pinterest: pinterest, youtube: youtube,
53
- instagram: instagram, facebook: facebook, linkedin: linkedin,
54
- linkedin_page: linkedin_page, tiktok: tiktok, x: x, bluesky: bluesky,
55
- mastodon: mastodon, google_business: google_business
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, pinterest: nil,
66
- youtube: nil, instagram: nil, facebook: nil,
67
- linkedin: nil, linkedin_page: nil, tiktok: nil,
68
- x: nil, bluesky: nil, mastodon: nil,
69
- google_business: nil)
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, pinterest: pinterest, youtube: youtube,
77
- instagram: instagram, facebook: facebook, linkedin: linkedin,
78
- linkedin_page: linkedin_page, tiktok: tiktok, x: x, bluesky: bluesky,
79
- mastodon: mastodon, google_business: google_business
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:, pinterest:, youtube:,
139
- instagram:, facebook:, linkedin:, linkedin_page:,
140
- tiktok:, x:, bluesky:, mastodon:, google_business:)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniSocials
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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-07-15 00:00:00.000000000 Z
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