omnisocials 0.8.0 → 0.11.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: 43d0cc71e753d98ddbaa8b1732f140ecfc3d578d02a8e8df81e248c6c9c75131
4
- data.tar.gz: a66ae890324f9adc7b37b57add4e76ce1be1b110d4b15f751c611bcbfa6adf61
3
+ metadata.gz: 473f213709ed52c13f70cd2b84c26b7d92c2355731dfc0fb676d3b0f3949fbac
4
+ data.tar.gz: 423f50628c3c4dae0b98d3c486337a573cb32972af6cdf74cd41a8646899f162
5
5
  SHA512:
6
- metadata.gz: 0b30af0ebca987fe7bebb4710d7648d2c0361e8709cdf716897d4f1f82808dc916159742dcae7c9c0512863e01f68435d642a9341e8254d9cc11511459b496ce
7
- data.tar.gz: e50bd7f05fe6a2e516ff9ea31bb28307af242c4118ca1a30583008b88591e3b2d9c080fb50447137133f036e21d6fed8721b90bd380cfb670dc3870868ac7710
6
+ metadata.gz: 5a047afd2262cd53375d22fef8b7c79ed254fddf8e5057cdb4cdd909308821bc19c81b73cddf04ad736cecca998bf0ee0f974f6205d2e0bd4472547ee4464dfa
7
+ data.tar.gz: 27c340cc9e7954eae71506ed026b10b1421130cb59c283cab8fdbac3016759b5e449c406367579acbc3cf4c4c1b70fb151750350d8e6bbb27fe330eaaf49cba3
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OmniSocials Ruby SDK
2
2
 
3
- The official Ruby client for the [OmniSocials](https://omnisocials.com) Public API. Schedule and publish posts, upload media, and read analytics across Instagram, Facebook, LinkedIn, YouTube, TikTok, X, Pinterest, Bluesky, Threads, Mastodon, and Google Business.
3
+ The official Ruby SDK for OmniSocials: post, schedule, reply and report on 12 social platforms (Instagram, Facebook, LinkedIn, X, TikTok, YouTube, Threads, Pinterest, Bluesky, Mastodon, Google Business and Telegram) from one API. Guide: [omnisocials.com/sdk/ruby](https://omnisocials.com/sdk/ruby)
4
4
 
5
5
  Full API reference: [https://docs.omnisocials.com](https://docs.omnisocials.com)
6
6
 
@@ -30,7 +30,7 @@ puts post["data"]["id"]
30
30
 
31
31
  ## Authentication
32
32
 
33
- Create an API key in the OmniSocials app under **Settings -> API Keys**. Keys look like `omsk_live_...` (or `omsk_test_...` for test keys) and are bound to one workspace.
33
+ Create an API key in the OmniSocials app under **Settings -> API Keys**. Keys start with `omsk_live_` and are bound to one workspace. There are no test keys and no test mode: every key works on your real connected accounts. To try things safely, leave out `scheduled_at` so the post stays a draft (a draft never publishes on its own), or use a separate workspace with test accounts.
34
34
 
35
35
  Pass the key explicitly or set the `OMNISOCIALS_API_KEY` environment variable:
36
36
 
@@ -65,6 +65,54 @@ post = client.posts.create_and_publish(
65
65
  )
66
66
  ```
67
67
 
68
+ ### Planned drafts
69
+
70
+ Pass `draft: true` with `scheduled_at` to store the post as a draft that keeps its time. It shows on the dashboard calendar at that slot with a dashed outline and the scheduler never publishes it: a person schedules it from the composer, or you do with `update` and `draft: false`. Use it when an agent proposes a slot that a human should confirm. `draft: true` is not allowed with `publish_now` or `approval_workflow_id` (`400 validation_error`). On `update`, `draft: false` without `scheduled_at` is a `400 validation_error` when the draft has no time or its time is in the past (pass a new `scheduled_at` then), and `draft: true` on a scheduled post unschedules it while keeping the time.
71
+
72
+ ```ruby
73
+ proposal = client.posts.create(
74
+ content: "Proposed for Friday morning",
75
+ channels: ["instagram", "linkedin"],
76
+ scheduled_at: "2026-08-01T09:00:00Z",
77
+ draft: true
78
+ )
79
+ puts proposal["data"]["status"], proposal["data"]["schedule_at"] # draft, the time is kept
80
+
81
+ # The human agrees: schedule it at the time it already carries
82
+ client.posts.update(proposal["data"]["id"], draft: false)
83
+ ```
84
+
85
+ ### Comment-to-DM automations
86
+
87
+ Answer a keyword in an Instagram comment with a DM, once per person, with an optional public reply under the comment. Pass `comment_dm_automation:` on `create` (Instagram must be in `channels`) and the automation is bound to that post; it starts listening once the post is live. Needs the opt-in `automations:write` scope on the key. `client.automations` manages the automations themselves (`list`, `get`, `create`, `update`, `delete`) and reads their activity (`list_events`, `automations:read`). On `update`, `comment_dm_automation:` as a Hash replaces the post's automation and an explicit `nil` removes it. Instagram rules: one private reply per comment, within 7 days; OmniSocials caps sends at 180 DMs an hour per account.
88
+
89
+ ```ruby
90
+ post = client.posts.create(
91
+ content: "Comment GUIDE and I will send you the link",
92
+ channels: ["instagram"],
93
+ scheduled_at: "2026-08-01T09:00:00Z",
94
+ media_urls: ["https://example.com/guide-cover.jpg"],
95
+ comment_dm_automation: {
96
+ "trigger" => { "keywords" => ["guide"], "match" => "word" },
97
+ "actions" => {
98
+ "dm" => { "text" => "Here is the guide: https://example.com/guide" },
99
+ "public_reply" => { "enabled" => true, "variations" => ["Sent you a DM!", "Check your inbox"] }
100
+ },
101
+ "limits" => { "max_sends" => 500 }
102
+ }
103
+ )
104
+ automation_id = post["data"]["comment_dm_automation"]["id"]
105
+
106
+ # Later: who was answered, and why someone was skipped
107
+ events = client.automations.list_events(automation_id, outcome: "sent")
108
+ puts events["counts"] # { "sent" => 12, "skipped" => 3 }
109
+ events["data"].each do |event|
110
+ puts [event["person"]["username"], event["outcome"], event["reason_label"]].join(" ")
111
+ end
112
+ ```
113
+
114
+ The events response is `{ "data", "counts", "pagination" }`, not the plain list: `counts` is the number of events per outcome (`sent`, `skipped`, `failed`, `pending`). An automation can also listen on a post published outside OmniSocials (`client.automations.create(instagram_media_id: ..., actions: ...)`), on the next post the workspace publishes (`trigger: { "scope" => "next_post" }`) or on every post (`"scope" => "any"`), and on DMs and story replies (`trigger: { "kinds" => ["dm", "story_reply"], ... }`).
115
+
68
116
  ### Per-media alt text
69
117
 
70
118
  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.
@@ -103,6 +151,32 @@ post = client.posts.create(
103
151
 
104
152
  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
153
 
154
+ ### Telegram channels and groups
155
+
156
+ `telegram` is a channel or group the workspace connected in OmniSocials; the OmniSocials bot posts to it. The `telegram` options: `silent`, `link_preview`, `pin`, `protect_content`, `spoiler` and `caption_above_media` (booleans), up to 3 `buttons` (`{ text, url }`, not on an album or a poll), a native `poll` (2 to 12 options of up to 100 characters, `open_period` 5 to 2628000 seconds, `multiple`; the post text is the question, max 300 characters, no media) and `first_comment` (a reply under the post). Text up to 4,096 characters; with media, text over 1,024 characters is sent as a reply under the media. Albums hold up to 10 photos and videos, files up to 50 MB, no Stories. Once sent, the post's `telegram.result` carries the message ids and the link. Telegram gives bots no view counts: analytics carry the reactions and the poll votes.
157
+
158
+ ```ruby
159
+ client.posts.create(
160
+ content: "The new collection is live. Tap the button to shop.",
161
+ channels: ["telegram"],
162
+ scheduled_at: "2026-11-02T09:00:00Z",
163
+ media_urls: ["https://example.com/collection.jpg"],
164
+ telegram: {
165
+ "silent" => true,
166
+ "pin" => true,
167
+ "buttons" => [{ "text" => "Shop now", "url" => "https://example.com/shop" }],
168
+ "first_comment" => "Questions? Reply to this message."
169
+ }
170
+ )
171
+
172
+ # A native poll: the text is the question.
173
+ client.posts.create(
174
+ content: "Which day suits the live Q&A?",
175
+ channels: ["telegram"],
176
+ telegram: { "poll" => { "options" => %w[Tuesday Thursday Saturday], "open_period" => 86_400 } }
177
+ )
178
+ ```
179
+
106
180
  ### Chained threads (X, Bluesky, Mastodon, Threads)
107
181
 
108
182
  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):
@@ -350,9 +424,11 @@ accounts = client.analytics.accounts(platform: "instagram")
350
424
 
351
425
  ### Best times to post
352
426
 
427
+ The response is not wrapped in `data`. `platform`, `timezone`, `basis`, `grid` and `recommendations` sit at the top level.
428
+
353
429
  ```ruby
354
430
  best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amsterdam")
355
- best["data"]["best_times"].each { |slot| puts slot }
431
+ best["recommendations"].each { |slot| puts "#{slot["day"]} #{slot["time"]} #{slot["score"]}" }
356
432
  ```
357
433
 
358
434
  ## Locations (Instagram and Threads place tagging)
@@ -387,6 +463,32 @@ client.posts.create(
387
463
 
388
464
  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.
389
465
 
466
+ ## Pinterest product tags
467
+
468
+ Tag products on a Pin so people can shop the items in the image. `client.pinterest.list_products` returns the product Pins of the connected Pinterest account; pass their `pin_id` values (max 24) as `"product_tags"` in the `pinterest` options of the post. Only product Pins of your own account can be tagged; products of other merchants cannot. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the outcome is on the post as `pinterest["product_tags_result"]` (`requested`, `tagged`, `skipped`, `error`).
469
+
470
+ ```ruby
471
+ result = client.pinterest.list_products
472
+
473
+ if result["error"]
474
+ # HTTP 200 without "products": pinterest_not_connected,
475
+ # pinterest_catalog_access_required or platform_error
476
+ warn "#{result["error"]["code"]}: #{result["error"]["message"]}"
477
+ else
478
+ product_tags = result["products"].first(3).map { |product| product["pin_id"] }
479
+
480
+ client.posts.create(
481
+ content: "Our summer picks",
482
+ channels: ["pinterest"],
483
+ media_urls: ["https://example.com/summer-look.jpg"],
484
+ scheduled_at: "2026-08-01T09:00:00Z",
485
+ pinterest: { "board_id" => "1234567890", "title" => "Summer picks", "product_tags" => product_tags }
486
+ )
487
+ end
488
+ ```
489
+
490
+ Without `source:` the list reads the Pinterest catalog (with `price`, `currency`, `availability` and `item_id`) when the connection has catalog access, else the account's own Pins. Catalog access is given one time in the OmniSocials composer: Pinterest options, Add products, Connect catalog. `source: "pins"` scans up to 250 Pins per call, so `"products"` can be empty while `"bookmark"` is set; call again with `bookmark: result["bookmark"]`. To check one Pin id or Pin link before you post, call `client.pinterest.validate_product("813744226420795884")`. On `posts.update` the `pinterest` hash replaces the stored one, so leave `"product_tags"` out to remove the tags.
491
+
390
492
  ## Inbox
391
493
 
392
494
  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`.
@@ -60,9 +60,10 @@ module OmniSocials
60
60
 
61
61
  attr_reader :api_key, :base_url, :timeout, :max_retries,
62
62
  :posts, :media, :folders, :hashtag_sets, :approval_workflows,
63
- :accounts, :analytics, :audio, :locations, :inbox, :webhooks
63
+ :accounts, :analytics, :audio, :locations, :pinterest,
64
+ :automations, :inbox, :webhooks
64
65
 
65
- # api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
66
+ # api_key - API key (omsk_live_*). Falls back to the
66
67
  # OMNISOCIALS_API_KEY environment variable. Raises
67
68
  # OmniSocials::AuthenticationError when neither is set.
68
69
  # base_url - API base URL (default https://api.omnisocials.com/v1).
@@ -94,6 +95,8 @@ module OmniSocials
94
95
  @analytics = Resources::Analytics.new(self)
95
96
  @audio = Resources::Audio.new(self)
96
97
  @locations = Resources::Locations.new(self)
98
+ @pinterest = Resources::Pinterest.new(self)
99
+ @automations = Resources::Automations.new(self)
97
100
  @inbox = Resources::Inbox.new(self)
98
101
  @webhooks = Resources::Webhooks.new(self)
99
102
  end
@@ -40,7 +40,9 @@ module OmniSocials
40
40
  end
41
41
 
42
42
  # GET /analytics/best-times - best times to post for a platform,
43
- # derived from the workspace's own engagement history.
43
+ # derived from the workspace's own engagement history. The response has
44
+ # no "data" wrapper: "platform", "timezone", "basis", "grid" and
45
+ # "recommendations" sit at the top level.
44
46
  def best_times(platform:, timezone: nil)
45
47
  @client.request(
46
48
  "GET", "/analytics/best-times",
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniSocials
4
+ module Resources
5
+ # Automations resource: comment-to-DM automations (list, get, create,
6
+ # update, delete, events). An automation answers a keyword in an
7
+ # Instagram comment (or a DM, or a story reply) with a DM, once per
8
+ # person, with an optional public reply under the comment. The scopes
9
+ # are opt-in on the API key: automations:read for list / get /
10
+ # list_events, automations:write for create / update / delete (and for
11
+ # comment_dm_automation on posts).
12
+ class Automations
13
+ def initialize(client)
14
+ @client = client
15
+ end
16
+
17
+ # GET /automations - list the workspace's comment-to-DM automations,
18
+ # optionally by status ("active", "paused", "completed") or by the
19
+ # OmniSocials post_id they are bound to. Requires the automations:read
20
+ # scope (opt-in on the API key).
21
+ def list(status: nil, post_id: nil)
22
+ @client.request(
23
+ "GET", "/automations",
24
+ query: { "status" => status, "post_id" => post_id }
25
+ )
26
+ end
27
+
28
+ # GET /automations/{id} - fetch a single automation. Requires the
29
+ # automations:read scope (opt-in on the API key).
30
+ def get(automation_id)
31
+ @client.request("GET", "/automations/#{automation_id}")
32
+ end
33
+
34
+ # POST /automations - create a comment-to-DM automation.
35
+ #
36
+ # A keyword in an Instagram comment (or a DM, or a story reply) is
37
+ # answered with a DM, once per person, with an optional public reply
38
+ # under the comment. Bind it to one post with post_id (an OmniSocials
39
+ # post that goes to Instagram, scheduled or live; one automation per
40
+ # post) or instagram_media_id (a post published outside OmniSocials),
41
+ # not both; a trigger scope of "next_post" or "any" needs neither.
42
+ #
43
+ # `actions` is required: { "dm" => { "text" => "..." } } (max 1000
44
+ # characters) plus an optional "public_reply" => { "enabled" => true,
45
+ # "variations" => [...] } (comment kind only, up to 10 of 300
46
+ # characters). `trigger` is { "kinds" => ["comment"], "scope" =>
47
+ # "post", "keywords" => [...], "match" => "word" }: kinds one or more
48
+ # of "comment", "dm", "story_reply"; keywords up to 20 of 60
49
+ # characters, required unless match is "any"; match is "word",
50
+ # "contains" or "any". `limits` is { "max_sends" => 500, "stop_at" =>
51
+ # "2026-12-31T00:00:00Z" } (both optional). `status` is "active"
52
+ # (default) or "paused". Instagram rules: one private reply per
53
+ # comment, within 7 days; OmniSocials caps sends at 180 DMs an hour per
54
+ # account.
55
+ #
56
+ # Requires the automations:write scope (opt-in on the API key).
57
+ # Errors: 400 validation_error, 400 post_not_instagram, 400
58
+ # post_required, 404 post_not_found, 404 instagram_media_not_found,
59
+ # 409 instagram_not_connected, 409 instagram_needs_reauth, 409
60
+ # automation_exists (the error body also carries automation_id).
61
+ def create(actions:, trigger: nil, limits: nil, name: nil, post_id: nil,
62
+ instagram_media_id: nil, status: nil)
63
+ body = automation_body(
64
+ name: name, post_id: post_id, instagram_media_id: instagram_media_id,
65
+ trigger: trigger, actions: actions, limits: limits, status: status
66
+ )
67
+ @client.request("POST", "/automations", json: body)
68
+ end
69
+
70
+ # PATCH /automations/{id} - partial update.
71
+ #
72
+ # Each section (trigger, actions, limits) is merged into the stored
73
+ # one; keys you omit keep their value: new trigger "keywords" keep the
74
+ # kinds, the scope and the match; a new actions "dm" "text" keeps the
75
+ # public reply; limits: { "stop_at" => ... } keeps max_sends.
76
+ # "public_reply" => { "enabled" => false } or { "variations" => [] }
77
+ # turns the public reply off; limits: { "max_sends" => nil } removes
78
+ # that limit. status: "paused" pauses and "active" resumes; another
79
+ # post_id / instagram_media_id moves the automation to that post. A
80
+ # "completed" automation starts again when its limits or trigger
81
+ # change. Requires the automations:write scope. 400 no_fields when
82
+ # nothing is passed.
83
+ def update(automation_id, name: nil, post_id: nil, instagram_media_id: nil,
84
+ trigger: nil, actions: nil, limits: nil, status: nil)
85
+ body = automation_body(
86
+ name: name, post_id: post_id, instagram_media_id: instagram_media_id,
87
+ trigger: trigger, actions: actions, limits: limits, status: status
88
+ )
89
+ @client.request("PATCH", "/automations/#{automation_id}", json: body)
90
+ end
91
+
92
+ # DELETE /automations/{id} - delete an automation. Returns nil (204).
93
+ # Requires the automations:write scope.
94
+ def delete(automation_id)
95
+ @client.request("DELETE", "/automations/#{automation_id}")
96
+ end
97
+
98
+ # GET /automations/{id}/events - the automation's activity: one event
99
+ # per person it answered, skipped or failed on. outcome filters on
100
+ # "sent", "skipped", "failed" or "pending"; limit is 1..100 (default
101
+ # 25), offset skips. Requires the automations:read scope.
102
+ #
103
+ # The response is not the plain list envelope: { "data" => [...],
104
+ # "counts" => { "sent" => 12, "skipped" => 3 }, "pagination" => {...} },
105
+ # where counts is the number of events per outcome (an outcome with no
106
+ # events is left out). Each event carries kind ("comment", "dm",
107
+ # "story_reply"), outcome, reason / reason_label, person (id,
108
+ # username), trigger_id, trigger_text, message (the DM sent),
109
+ # public_reply, platform_message_id, created_at and sent_at.
110
+ def list_events(automation_id, outcome: nil, limit: nil, offset: nil)
111
+ @client.request(
112
+ "GET", "/automations/#{automation_id}/events",
113
+ query: { "outcome" => outcome, "limit" => limit, "offset" => offset }
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def automation_body(name:, post_id:, instagram_media_id:, trigger:,
120
+ actions:, limits:, status:)
121
+ Internal.drop_nil(
122
+ {
123
+ "name" => name,
124
+ "post_id" => post_id,
125
+ "instagram_media_id" => instagram_media_id,
126
+ "trigger" => trigger,
127
+ "actions" => actions,
128
+ "limits" => limits,
129
+ "status" => status
130
+ }
131
+ )
132
+ end
133
+ end
134
+ end
135
+ end
@@ -8,7 +8,8 @@ module OmniSocials
8
8
  @client = client
9
9
  end
10
10
 
11
- # GET /media - list library files.
11
+ # GET /media - list library files. Each item carries "width" and
12
+ # "height" (pixels; nil for videos and older files).
12
13
  def list(limit: nil, offset: nil, search: nil, folder_id: nil)
13
14
  @client.request(
14
15
  "GET", "/media",
@@ -98,6 +99,16 @@ module OmniSocials
98
99
  #
99
100
  # Provide one of: a public `url`, an existing `media_id`, or
100
101
  # `size_bytes` + `mime`.
102
+ #
103
+ # The result carries "file" (size_bytes, mime, size_known, width,
104
+ # height; the pixel size is known for library images, nil otherwise),
105
+ # "connected_platforms", "compatible", "warnings", "summary" and "fit":
106
+ # one entry per connected channel with a fit rule for an IMAGE as a
107
+ # feed post (platform, display_name, fits, ratio, shown = "whole" |
108
+ # "cut", target, default_action = "crop" | "keep" | nil, message).
109
+ # fits false = the ratio is outside the window the channel shows
110
+ # uncropped; default_action is what a post create does unless the
111
+ # post's media_fit says otherwise. Empty for videos and unknown sizes.
101
112
  def check(url: nil, media_id: nil, size_bytes: nil, mime: nil)
102
113
  body = Internal.drop_nil(
103
114
  {
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniSocials
4
+ module Resources
5
+ # Pinterest resource: product Pins for product tagging (list + validate).
6
+ class Pinterest
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # GET /pinterest/products - list the product Pins of the connected
12
+ # Pinterest account. Pass a result's pin_id in
13
+ # pinterest: { "product_tags" => [...] } on post create/update to tag
14
+ # the product on the Pin (max 24 per Pin). Pinterest only accepts a
15
+ # product Pin that is public, belongs to the same account and links to
16
+ # a website that account claimed; products of other merchants cannot
17
+ # be tagged.
18
+ #
19
+ # source is "catalog" or "pins". "catalog" reads the Pinterest catalog
20
+ # (with price, currency, availability, item_id) and needs catalog
21
+ # access, which is given one time in the OmniSocials composer
22
+ # (Pinterest options, Add products, Connect catalog); product_group_id
23
+ # and page_size (1..100, default 25) apply to this source only. "pins"
24
+ # reads the account's own Pins and works on every connection; one call
25
+ # scans up to 250 Pins, so "products" can be empty while "bookmark" is
26
+ # set (call again with bookmark:). When source is left out the API
27
+ # uses "catalog" when the connection has catalog access, else "pins".
28
+ #
29
+ # Response (not the usual "data" envelope):
30
+ # { "products" => [{ pin_id, title, description, link, image_url,
31
+ # price, currency, availability, item_id }], "bookmark", "source",
32
+ # "catalog_access" } plus "product_groups" and "product_group_id" for
33
+ # the catalog source, or { "error" => { "code", "message" } } without
34
+ # "products" when the list could not be read, both with HTTP 200. code
35
+ # is one of "pinterest_not_connected",
36
+ # "pinterest_catalog_access_required" or "platform_error". A bad source
37
+ # or product_group_id raises a 400 with the standard error envelope.
38
+ def list_products(source: nil, product_group_id: nil, bookmark: nil,
39
+ page_size: nil)
40
+ @client.request(
41
+ "GET", "/pinterest/products",
42
+ query: {
43
+ "source" => source,
44
+ "product_group_id" => product_group_id,
45
+ "bookmark" => bookmark,
46
+ "page_size" => page_size
47
+ }
48
+ )
49
+ end
50
+
51
+ # GET /pinterest/products/validate?id= - check whether a Pin can be
52
+ # used in pinterest product_tags before creating the post. id is a Pin
53
+ # id or a Pin link (https://www.pinterest.com/pin/<id>/). Response:
54
+ # { "valid", "pin_id", ... }; "unverified" => true means the check
55
+ # could not run and the publish step is the final check.
56
+ def validate_product(id)
57
+ @client.request("GET", "/pinterest/products/validate", query: { "id" => id })
58
+ end
59
+ end
60
+ end
61
+ end
@@ -46,6 +46,25 @@ module OmniSocials
46
46
  # POST /posts/create - create a post (draft, or scheduled when
47
47
  # scheduled_at is set).
48
48
  #
49
+ # draft: true with scheduled_at stores a planned draft: the post is
50
+ # "draft" and keeps that time, shows on the dashboard calendar at that
51
+ # slot with a dashed outline, and the scheduler never publishes it. A
52
+ # person schedules it from the composer, or you do with #update and
53
+ # draft: false. Use it when an agent proposes a slot that a human
54
+ # should confirm. Not allowed with publish_now or approval_workflow_id
55
+ # (400 validation_error). Without scheduled_at it changes nothing.
56
+ #
57
+ # media_fit is the image fit per channel for feed images whose ratio is
58
+ # outside what a channel shows uncropped: a Hash of platform id
59
+ # ("instagram", "pinterest", ...) to "auto", "crop" or "keep". auto
60
+ # (default): Instagram crops a tall or wide image to 4:5 / 1.91:1 around
61
+ # the subject (Instagram cuts it to that window anyway and Meta cannot
62
+ # boost a feed post outside it); Pinterest keeps a Pin taller than 2:3
63
+ # and reports it. crop / keep force it per channel. Every decision comes
64
+ # back in the same "warnings" array with code "image_fit" (action is
65
+ # "cropped", "kept" or "crop_failed", with platform, media_url, ratio,
66
+ # target and crop_url).
67
+ #
49
68
  # approval_workflow_id (a workflow id from client.approval_workflows.list)
50
69
  # routes the post through a saved approval workflow: it is created as
51
70
  # in_approval (approval_status "pending") instead of scheduled, the
@@ -86,6 +105,43 @@ module OmniSocials
86
105
  # disabled on production and create/update/publish return a 400 (also a
87
106
  # 400 validation_error asking you to reconnect Threads when the
88
107
  # connection lacks the threads_location_tagging permission).
108
+ #
109
+ # Pinterest posts can tag products: pass
110
+ # pinterest: { "board_id" => "...", "product_tags" => [...] } with up
111
+ # to 24 product Pins of the connected Pinterest account, each as a Pin
112
+ # id String (see pinterest.list_products) or a Pin link. Products of
113
+ # other merchants cannot be tagged. The tags are added right after the
114
+ # Pin is published; a product Pinterest refuses never fails the post,
115
+ # and the Post's "pinterest" block then carries "product_tags_result"
116
+ # (requested, tagged, skipped, error). More than 24 entries or an
117
+ # invalid entry raises a 400 validation_error.
118
+ #
119
+ # telegram holds the Telegram options (the connected channel or group,
120
+ # posted by the OmniSocials bot): "silent", "link_preview", "pin",
121
+ # "protect_content", "spoiler", "caption_above_media" (booleans), up to
122
+ # 3 "buttons" ({ "text" => ..., "url" => ... }, not on an album or a
123
+ # poll), a native "poll" ({ "options" => 2 to 12 of up to 100
124
+ # characters, "open_period" => 5 to 2628000 seconds, "multiple" => true
125
+ # }; the post text is the question, max 300 characters, no media) and
126
+ # "first_comment" (a reply under the post). Text up to 4,096
127
+ # characters; with media, text over 1,024 characters is sent as a reply
128
+ # under the media. No Stories. On #update every key merges into the
129
+ # stored options. Once sent, the Post's "telegram" block carries
130
+ # "result" (message ids and links).
131
+ #
132
+ # comment_dm_automation binds a comment-to-DM automation to the post
133
+ # (Instagram must be in channels): a keyword in a comment is answered
134
+ # with a DM, once per person, with an optional public reply under the
135
+ # comment. Shape: { "trigger" => { "keywords" => ["guide"], "match" =>
136
+ # "word" }, "actions" => { "dm" => { "text" => "..." }, "public_reply"
137
+ # => { "enabled" => true, "variations" => [...] } }, "limits" =>
138
+ # { "max_sends" => 500 } } (see automations.create for the fields).
139
+ # Needs the automations:write scope on the key (403
140
+ # insufficient_scope, checked before anything else); a bad Hash is a
141
+ # 400 validation_error whose message starts with
142
+ # "comment_dm_automation: ". The response then carries
143
+ # data["comment_dm_automation"] (the automation, with "post" nil); it
144
+ # starts listening once the post is live on Instagram.
89
145
  def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
90
146
  media_urls: nil, type: nil, source: nil, link_url: nil,
91
147
  link_title: nil, link_description: nil, link_thumbnail_url: nil,
@@ -95,7 +151,9 @@ module OmniSocials
95
151
  instagram: nil, facebook: nil, linkedin: nil,
96
152
  linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
97
153
  mastodon: nil, threads: nil, google_business: nil,
98
- linkedin_poll: nil, approval_workflow_id: nil)
154
+ telegram: nil, linkedin_poll: nil,
155
+ approval_workflow_id: nil, draft: nil, media_fit: nil,
156
+ comment_dm_automation: nil)
99
157
  body = create_body(
100
158
  content: content, channels: channels, scheduled_at: scheduled_at,
101
159
  media_ids: media_ids, media_urls: media_urls, type: type,
@@ -108,15 +166,19 @@ module OmniSocials
108
166
  youtube: youtube, instagram: instagram, facebook: facebook,
109
167
  linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
110
168
  x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
111
- google_business: google_business, linkedin_poll: linkedin_poll,
112
- approval_workflow_id: approval_workflow_id
169
+ google_business: google_business, telegram: telegram,
170
+ linkedin_poll: linkedin_poll,
171
+ approval_workflow_id: approval_workflow_id, draft: draft,
172
+ media_fit: media_fit, comment_dm_automation: comment_dm_automation
113
173
  )
114
174
  @client.request("POST", "/posts/create", json: body)
115
175
  end
116
176
 
117
177
  # POST /posts/create-and-publish - create and publish immediately.
118
- # See #create for the "warnings" array and the 402
119
- # "x_credits_insufficient" credit gate on X link posts.
178
+ # See #create for the "warnings" array (X link posts, and the
179
+ # "image_fit" entries steered by media_fit), the 402
180
+ # "x_credits_insufficient" credit gate on X link posts and
181
+ # comment_dm_automation.
120
182
  def create_and_publish(content:, channels: nil, media_ids: nil,
121
183
  media_urls: nil, type: nil, source: nil,
122
184
  link_url: nil, link_title: nil, link_description: nil,
@@ -128,7 +190,8 @@ module OmniSocials
128
190
  facebook: nil, linkedin: nil, linkedin_page: nil,
129
191
  tiktok: nil, x: nil, bluesky: nil, mastodon: nil,
130
192
  threads: nil, google_business: nil,
131
- linkedin_poll: nil)
193
+ telegram: nil, linkedin_poll: nil, media_fit: nil,
194
+ comment_dm_automation: nil)
132
195
  body = create_body(
133
196
  content: content, channels: channels, scheduled_at: nil,
134
197
  media_ids: media_ids, media_urls: media_urls, type: type,
@@ -141,28 +204,53 @@ module OmniSocials
141
204
  youtube: youtube, instagram: instagram, facebook: facebook,
142
205
  linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
143
206
  x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
144
- google_business: google_business, linkedin_poll: linkedin_poll
207
+ google_business: google_business, telegram: telegram,
208
+ linkedin_poll: linkedin_poll,
209
+ media_fit: media_fit, comment_dm_automation: comment_dm_automation
145
210
  )
146
211
  @client.request("POST", "/posts/create-and-publish", json: body)
147
212
  end
148
213
 
149
214
  # PATCH /posts/{id} - update a draft or scheduled post.
150
215
  #
216
+ # draft: false without scheduled_at schedules a planned draft at the
217
+ # scheduled_at it already carries (400 validation_error when it has
218
+ # none or it is in the past). draft: true without scheduled_at
219
+ # unschedules a scheduled post: it keeps its time and stays on the
220
+ # calendar as a dashed draft, nothing publishes; a post waiting for
221
+ # approval leaves its workflow. draft: true with scheduled_at moves the
222
+ # time and keeps (or makes) the post a draft.
223
+ #
224
+ # media_fit (same shape as on #create) alone re-decides the stored
225
+ # images: "keep" gives the original back, "crop" crops an image that
226
+ # was kept. Like #create, the response can carry a top-level "warnings"
227
+ # array with "image_fit" entries when the update changes the images or
228
+ # media_fit.
229
+ #
151
230
  # Only top-level nils are dropped from the body, so passing e.g.
152
231
  # x: { "thread_parts" => nil } still clears an X thread (reverts the
153
232
  # post to single-tweet mode). The same applies to bluesky, mastodon
154
233
  # and threads thread parts, and to a Threads location tag:
155
234
  # threads: { "location_id" => nil } (or "location" => nil) clears it.
156
235
  #
236
+ # pinterest replaces the stored Pinterest options wholesale, so leave
237
+ # "product_tags" out (or send []) to remove the product tags.
238
+ #
157
239
  # See #create for the 402 "x_credits_insufficient" credit gate that
158
240
  # can also refuse an update to a scheduled X link post.
241
+ #
242
+ # comment_dm_automation (same shape as on #create) as a Hash replaces
243
+ # the post's comment-to-DM automation, an explicit nil removes it, and
244
+ # leaving it out keeps it. Needs the automations:write scope.
159
245
  def update(post_id, content: nil, scheduled_at: nil, channels: nil,
160
246
  media_ids: nil, media_urls: nil, type: nil, location_id: nil,
161
247
  collaborators: nil, user_tags: nil, pinterest: nil,
162
248
  youtube: nil, instagram: nil, facebook: nil, linkedin: nil,
163
249
  linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
164
250
  mastodon: nil, threads: nil, google_business: nil,
165
- linkedin_poll: nil)
251
+ telegram: nil, linkedin_poll: nil, draft: nil,
252
+ media_fit: nil,
253
+ comment_dm_automation: OmniSocials::NOT_GIVEN)
166
254
  body = Internal.drop_nil(
167
255
  {
168
256
  "content" => content,
@@ -186,9 +274,15 @@ module OmniSocials
186
274
  "mastodon" => mastodon,
187
275
  "threads" => threads,
188
276
  "google_business" => google_business,
189
- "linkedin_poll" => linkedin_poll
277
+ "telegram" => telegram,
278
+ "linkedin_poll" => linkedin_poll,
279
+ "draft" => draft,
280
+ "media_fit" => media_fit
190
281
  }
191
282
  )
283
+ # An explicit nil removes the post's automation, so it must survive
284
+ # drop_nil; NOT_GIVEN (the default) leaves it untouched.
285
+ body["comment_dm_automation"] = comment_dm_automation unless comment_dm_automation.is_a?(NotGiven)
192
286
  @client.request("PATCH", "/posts/#{post_id}", json: body)
193
287
  end
194
288
 
@@ -264,7 +358,8 @@ module OmniSocials
264
358
  pinterest:, youtube:, instagram:, facebook:, linkedin:,
265
359
  linkedin_page:, tiktok:, x:, bluesky:, mastodon:,
266
360
  threads:, google_business:, linkedin_poll:,
267
- approval_workflow_id: nil)
361
+ telegram: nil, approval_workflow_id: nil, draft: nil,
362
+ media_fit: nil, comment_dm_automation: nil)
268
363
  Internal.drop_nil(
269
364
  {
270
365
  "content" => content,
@@ -297,8 +392,12 @@ module OmniSocials
297
392
  "mastodon" => mastodon,
298
393
  "threads" => threads,
299
394
  "google_business" => google_business,
395
+ "telegram" => telegram,
300
396
  "linkedin_poll" => linkedin_poll,
301
- "approval_workflow_id" => approval_workflow_id
397
+ "approval_workflow_id" => approval_workflow_id,
398
+ "draft" => draft,
399
+ "media_fit" => media_fit,
400
+ "comment_dm_automation" => comment_dm_automation
302
401
  }
303
402
  )
304
403
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniSocials
4
- VERSION = "0.8.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/omnisocials.rb CHANGED
@@ -25,6 +25,8 @@ require_relative "omnisocials/resources/accounts"
25
25
  require_relative "omnisocials/resources/analytics"
26
26
  require_relative "omnisocials/resources/audio"
27
27
  require_relative "omnisocials/resources/locations"
28
+ require_relative "omnisocials/resources/pinterest"
29
+ require_relative "omnisocials/resources/automations"
28
30
  require_relative "omnisocials/resources/inbox"
29
31
  require_relative "omnisocials/resources/webhooks"
30
32
  require_relative "omnisocials/client"
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.8.0
4
+ version: 0.11.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-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-21 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,
@@ -29,20 +29,22 @@ files:
29
29
  - lib/omnisocials/resources/analytics.rb
30
30
  - lib/omnisocials/resources/approval_workflows.rb
31
31
  - lib/omnisocials/resources/audio.rb
32
+ - lib/omnisocials/resources/automations.rb
32
33
  - lib/omnisocials/resources/folders.rb
33
34
  - lib/omnisocials/resources/hashtag_sets.rb
34
35
  - lib/omnisocials/resources/inbox.rb
35
36
  - lib/omnisocials/resources/locations.rb
36
37
  - lib/omnisocials/resources/media.rb
38
+ - lib/omnisocials/resources/pinterest.rb
37
39
  - lib/omnisocials/resources/posts.rb
38
40
  - lib/omnisocials/resources/webhooks.rb
39
41
  - lib/omnisocials/version.rb
40
42
  - lib/omnisocials/webhooks.rb
41
- homepage: https://omnisocials.com
43
+ homepage: https://omnisocials.com/sdk/ruby
42
44
  licenses:
43
45
  - MIT
44
46
  metadata:
45
- homepage_uri: https://omnisocials.com
47
+ homepage_uri: https://omnisocials.com/sdk/ruby
46
48
  documentation_uri: https://docs.omnisocials.com
47
49
  source_code_uri: https://github.com/OmniSocials/omnisocials-ruby
48
50
  bug_tracker_uri: https://github.com/OmniSocials/omnisocials-ruby/issues