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 +4 -4
- data/README.md +105 -3
- data/lib/omnisocials/client.rb +5 -2
- data/lib/omnisocials/resources/analytics.rb +3 -1
- data/lib/omnisocials/resources/automations.rb +135 -0
- data/lib/omnisocials/resources/media.rb +12 -1
- data/lib/omnisocials/resources/pinterest.rb +61 -0
- data/lib/omnisocials/resources/posts.rb +110 -11
- data/lib/omnisocials/version.rb +1 -1
- data/lib/omnisocials.rb +2 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 473f213709ed52c13f70cd2b84c26b7d92c2355731dfc0fb676d3b0f3949fbac
|
|
4
|
+
data.tar.gz: 423f50628c3c4dae0b98d3c486337a573cb32972af6cdf74cd41a8646899f162
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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["
|
|
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`.
|
data/lib/omnisocials/client.rb
CHANGED
|
@@ -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, :
|
|
63
|
+
:accounts, :analytics, :audio, :locations, :pinterest,
|
|
64
|
+
:automations, :inbox, :webhooks
|
|
64
65
|
|
|
65
|
-
# api_key - API key (omsk_live_*
|
|
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
|
-
|
|
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,
|
|
112
|
-
|
|
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
|
|
119
|
-
# "
|
|
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,
|
|
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
|
-
"
|
|
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
|
data/lib/omnisocials/version.rb
CHANGED
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.
|
|
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-
|
|
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
|