omnisocials 0.1.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 +7 -0
- data/README.md +373 -0
- data/lib/omnisocials/client.rb +249 -0
- data/lib/omnisocials/errors.rb +77 -0
- data/lib/omnisocials/internal.rb +138 -0
- data/lib/omnisocials/resources/accounts.rb +25 -0
- data/lib/omnisocials/resources/analytics.rb +52 -0
- data/lib/omnisocials/resources/folders.rb +41 -0
- data/lib/omnisocials/resources/locations.rb +24 -0
- data/lib/omnisocials/resources/media.rb +128 -0
- data/lib/omnisocials/resources/posts.rb +173 -0
- data/lib/omnisocials/resources/webhooks.rb +61 -0
- data/lib/omnisocials/version.rb +5 -0
- data/lib/omnisocials/webhooks.rb +113 -0
- data/lib/omnisocials.rb +29 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6d8e63bac3e6cf2774234d627061b6ef9d2f2555329374da55bc37b2dc912593
|
|
4
|
+
data.tar.gz: 1f432caac9857e978a539b9382aeb11390a5e02637c97725a84f5b7146f14206
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '079b0f69fe85f2317bae07bf58246d49a150d6a5afaed56aac9c4dc3111dfc6713ac250c12591f6f8428923e91003333377b004a6f64fe8eb786660b9fb75eca'
|
|
7
|
+
data.tar.gz: 5cced0edb928d0b5600a94ca7a28a8c3a809bfc58eee18ef89683c665f04327aee7d215c7982af068144165718e87468b237e698b7d36a273013cb6503df206e
|
data/README.md
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
# OmniSocials Ruby SDK
|
|
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.
|
|
4
|
+
|
|
5
|
+
Full API reference: [https://docs.omnisocials.com](https://docs.omnisocials.com)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install omnisocials
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "omnisocials"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Ruby 3.0+. Zero runtime dependencies: built entirely on the Ruby standard library (Net::HTTP, OpenSSL, JSON).
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "omnisocials"
|
|
25
|
+
|
|
26
|
+
client = OmniSocials::Client.new # reads OMNISOCIALS_API_KEY from the environment
|
|
27
|
+
post = client.posts.create(content: "Hello from Ruby", channels: ["instagram", "linkedin"])
|
|
28
|
+
puts post["data"]["id"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Authentication
|
|
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.
|
|
34
|
+
|
|
35
|
+
Pass the key explicitly or set the `OMNISOCIALS_API_KEY` environment variable:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
require "omnisocials"
|
|
39
|
+
|
|
40
|
+
client = OmniSocials::Client.new(api_key: "omsk_live_...")
|
|
41
|
+
# or: export OMNISOCIALS_API_KEY=omsk_live_... and just OmniSocials::Client.new
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The client opens a fresh connection per request, so there is nothing to close. All responses are plain Hashes (parsed JSON, string keys), returned exactly as the API sends them.
|
|
45
|
+
|
|
46
|
+
## Posts
|
|
47
|
+
|
|
48
|
+
### Schedule a post
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
post = client.posts.create(
|
|
52
|
+
content: "Big announcement coming Friday",
|
|
53
|
+
channels: ["instagram", "facebook", "linkedin"],
|
|
54
|
+
scheduled_at: "2026-08-01T09:00:00Z",
|
|
55
|
+
media_urls: ["https://example.com/teaser.jpg"]
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Omit `scheduled_at` to create a draft, or use `create_and_publish` to publish immediately:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
post = client.posts.create_and_publish(
|
|
63
|
+
content: "We are live!",
|
|
64
|
+
channels: ["x", "bluesky"]
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Post to specific platforms with platform options
|
|
69
|
+
|
|
70
|
+
`content` can be a per-platform Hash (with `default` as the fallback), and each platform has its own options Hash:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
post = client.posts.create(
|
|
74
|
+
content: {
|
|
75
|
+
"default" => "New feature: best times to post",
|
|
76
|
+
"linkedin" => "We just shipped best times to post. Here is how it works."
|
|
77
|
+
},
|
|
78
|
+
channels: ["linkedin", "instagram", "youtube"],
|
|
79
|
+
media_urls: ["https://example.com/demo.mp4"],
|
|
80
|
+
scheduled_at: "2026-08-01T09:00:00Z",
|
|
81
|
+
instagram: { "share_to_feed" => true },
|
|
82
|
+
youtube: { "title" => "Best times to post, explained", "privacy_status" => "public" }
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
### X thread example
|
|
89
|
+
|
|
90
|
+
Pass 2 to 25 `thread_parts` to publish a chained thread instead of a single tweet (each part is at most 280 characters). Bluesky and Mastodon support the same `thread_parts` shape:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
post = client.posts.create(
|
|
94
|
+
content: "How we cut our API response times by 80%", # fallback text
|
|
95
|
+
channels: ["x"],
|
|
96
|
+
scheduled_at: "2026-08-01T15:00:00Z",
|
|
97
|
+
x: {
|
|
98
|
+
"thread_parts" => [
|
|
99
|
+
{ "text" => "How we cut our API response times by 80%. A thread:" },
|
|
100
|
+
{ "text" => "1/ We started by profiling every endpoint under real load." },
|
|
101
|
+
{ "text" => "2/ The biggest win: batching analytics reads into one query." },
|
|
102
|
+
{ "text" => "3/ Full write-up on the blog. Thanks for reading!" }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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
|
+
|
|
110
|
+
### Other post operations
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
posts = client.posts.list(status: "scheduled", limit: 50, offset: 0)
|
|
114
|
+
post = client.posts.get("123")
|
|
115
|
+
client.posts.update("123", scheduled_at: "2026-08-02T10:00:00Z")
|
|
116
|
+
client.posts.publish("123") # publish a draft or scheduled post now
|
|
117
|
+
client.posts.delete("123") # returns nil (204)
|
|
118
|
+
|
|
119
|
+
# Recent posts fetched live from the connected platforms (including content
|
|
120
|
+
# published outside OmniSocials). Requires the analytics:read scope.
|
|
121
|
+
recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tiktok"])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Media
|
|
125
|
+
|
|
126
|
+
### Upload from a URL (recommended, up to 1GB)
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
media = client.media.upload_from_url(
|
|
130
|
+
url: "https://example.com/promo.mp4",
|
|
131
|
+
name: "promo-august",
|
|
132
|
+
folder: "Campaigns"
|
|
133
|
+
)
|
|
134
|
+
media_id = media["data"]["id"]
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Files over 100MB are processed in the background: the response has `media["data"]["status"] == "processing"`; poll `client.media.get(media_id)` until it is `"ready"` before attaching it to a post.
|
|
138
|
+
|
|
139
|
+
### Upload a local file (multipart, max 100MB)
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
media = client.media.upload(file: "/path/to/image.jpg", name: "hero-shot")
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`file` accepts a file path (String or Pathname), an IO (`File.open("...", "rb")`, StringIO), or raw bytes as a binary-encoded String (e.g. from `File.binread`).
|
|
146
|
+
|
|
147
|
+
Uploading a PDF splits it into image slides (max 20 pages) and returns `slides` plus a `media_ids` array. Pass all of `media_ids`, in order, to `posts.create` to publish the deck as a carousel (a native swipeable document on LinkedIn, an image carousel elsewhere).
|
|
148
|
+
|
|
149
|
+
Every upload response also includes a `compatibility` block listing any connected platforms that would reject the file.
|
|
150
|
+
|
|
151
|
+
### Other media operations
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
files = client.media.list(search: "promo", folder_id: "42")
|
|
155
|
+
item = client.media.get("1001")
|
|
156
|
+
client.media.update("1001", name: "renamed", folder_id: "42")
|
|
157
|
+
client.media.update("1001", folder_id: nil) # move to the root ("All media")
|
|
158
|
+
client.media.delete("1001")
|
|
159
|
+
|
|
160
|
+
# Base64 upload (handy when you have bytes but no URL)
|
|
161
|
+
client.media.upload_from_base64(data: b64_string, mime_type: "image/png")
|
|
162
|
+
|
|
163
|
+
# Preflight compatibility BEFORE uploading
|
|
164
|
+
result = client.media.check(url: "https://example.com/huge.mp4")
|
|
165
|
+
puts result["summary"] unless result["compatible"]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Note the difference between omitting `folder_id` (leave the folder unchanged) and passing `folder_id: nil` (move the file to the root). The same applies to `parent_id` on `folders.update`.
|
|
169
|
+
|
|
170
|
+
### Large file uploads with a presigned URL
|
|
171
|
+
|
|
172
|
+
`create_upload_url` mints a one-time upload URL so any environment (a CI job, a sandbox, a script without the SDK) can upload a file without auth headers:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
grant = client.media.create_upload_url
|
|
176
|
+
# grant contains: upload_url, upload_token, expires_in_seconds (600),
|
|
177
|
+
# method ("POST"), content_type ("multipart/form-data")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
POST the file to `grant["upload_url"]` as multipart form data (field name `file`) with no auth headers. The token is single-use and expires after 10 minutes. The response JSON contains the created media item with its `id` (or a `media_ids` array for a PDF).
|
|
181
|
+
|
|
182
|
+
## Folders
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
folders = client.folders.list
|
|
186
|
+
folder = client.folders.create(name: "Campaigns")
|
|
187
|
+
sub = client.folders.create(name: "August", parent_id: folder["data"]["id"])
|
|
188
|
+
client.folders.update(sub["data"]["id"], name: "August 2026")
|
|
189
|
+
client.folders.update(sub["data"]["id"], parent_id: nil) # move to top level
|
|
190
|
+
client.folders.delete(folder["data"]["id"]) # files move to root, subfolders move up
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Accounts
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
accounts = client.accounts.list
|
|
197
|
+
accounts["data"].each do |account|
|
|
198
|
+
puts "#{account["platform"]}: #{account["username"]}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
account = client.accounts.get("instagram")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Analytics
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# One post
|
|
208
|
+
stats = client.analytics.post("123")
|
|
209
|
+
|
|
210
|
+
# Up to 100 posts in one request (avoids rate limit trouble when syncing)
|
|
211
|
+
bulk = client.analytics.posts(["123", "124", "125"])
|
|
212
|
+
|
|
213
|
+
# Workspace overview
|
|
214
|
+
overview = client.analytics.overview(period: "30d")
|
|
215
|
+
|
|
216
|
+
# Account-level stats (followers etc.)
|
|
217
|
+
accounts = client.analytics.accounts(platform: "instagram")
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Best times to post
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amsterdam")
|
|
224
|
+
best["data"]["best_times"].each { |slot| puts slot }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Locations (Instagram place tagging)
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
results = client.locations.search("Blue Bottle Coffee Oakland")
|
|
231
|
+
location_id = results["data"][0]["id"]
|
|
232
|
+
client.locations.validate(location_id)
|
|
233
|
+
|
|
234
|
+
client.posts.create(
|
|
235
|
+
content: "Great coffee here",
|
|
236
|
+
channels: ["instagram"],
|
|
237
|
+
media_urls: ["https://example.com/latte.jpg"],
|
|
238
|
+
location_id: location_id
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Webhooks
|
|
243
|
+
|
|
244
|
+
Subscribe to `post.scheduled`, `post.published`, and `post.failed` events:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
webhook = client.webhooks.create(
|
|
248
|
+
url: "https://example.com/hooks/omnisocials",
|
|
249
|
+
events: ["post.published", "post.failed"]
|
|
250
|
+
)
|
|
251
|
+
secret = webhook["data"]["secret"] # only shown once, store it
|
|
252
|
+
|
|
253
|
+
client.webhooks.list
|
|
254
|
+
client.webhooks.get(webhook["data"]["id"])
|
|
255
|
+
client.webhooks.update(webhook["data"]["id"], is_active: false)
|
|
256
|
+
client.webhooks.rotate_secret(webhook["data"]["id"]) # new secret, shown once
|
|
257
|
+
client.webhooks.delete(webhook["data"]["id"])
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Verifying webhook signatures (Rails example)
|
|
261
|
+
|
|
262
|
+
Every delivery is signed with HMAC-SHA256. The `X-OmniSocials-Signature` header has the form `t=<unix>,v1=<hex>`, signed over `"{timestamp}.{raw_body}"`. Use `OmniSocials::Webhooks.verify` with the raw request body (do not parse it first):
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
class OmnisocialsWebhooksController < ApplicationController
|
|
266
|
+
skip_before_action :verify_authenticity_token
|
|
267
|
+
|
|
268
|
+
def create
|
|
269
|
+
payload = request.body.read # raw body, exactly as received
|
|
270
|
+
signature = request.headers["X-OmniSocials-Signature"]
|
|
271
|
+
|
|
272
|
+
begin
|
|
273
|
+
event = OmniSocials::Webhooks.verify(
|
|
274
|
+
payload: payload,
|
|
275
|
+
signature: signature,
|
|
276
|
+
secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET"),
|
|
277
|
+
tolerance: 300
|
|
278
|
+
)
|
|
279
|
+
rescue OmniSocials::WebhookVerificationError
|
|
280
|
+
return head :bad_request
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if event["type"] == "post.published"
|
|
284
|
+
event["data"]["targets"].each do |target|
|
|
285
|
+
Rails.logger.info "#{target["platform"]} #{target["status"]} #{target["native_post_id"]}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
head :ok
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Verifying webhook signatures (plain Rack)
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
# config.ru
|
|
298
|
+
require "omnisocials"
|
|
299
|
+
|
|
300
|
+
app = lambda do |env|
|
|
301
|
+
request = Rack::Request.new(env)
|
|
302
|
+
payload = request.body.read
|
|
303
|
+
signature = env["HTTP_X_OMNISOCIALS_SIGNATURE"]
|
|
304
|
+
|
|
305
|
+
begin
|
|
306
|
+
event = OmniSocials::Webhooks.verify(
|
|
307
|
+
payload: payload,
|
|
308
|
+
signature: signature,
|
|
309
|
+
secret: ENV.fetch("OMNISOCIALS_WEBHOOK_SECRET")
|
|
310
|
+
)
|
|
311
|
+
rescue OmniSocials::WebhookVerificationError
|
|
312
|
+
next [400, { "content-type" => "application/json" }, ['{"error":"invalid signature"}']]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# handle event["type"] ...
|
|
316
|
+
[200, { "content-type" => "application/json" }, ['{"ok":true}']]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
run app
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`OmniSocials::Webhooks.verify` uses a constant-time comparison, rejects timestamps older than `tolerance` seconds (default 300) to block replays, and returns the parsed event Hash on success. Respond with any 2xx within 10 seconds to acknowledge; deliveries are retried up to 3 times.
|
|
323
|
+
|
|
324
|
+
## Error handling
|
|
325
|
+
|
|
326
|
+
Non-2xx responses raise typed exceptions with `status`, `code`, `message`, and the parsed `body`:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
begin
|
|
330
|
+
client.posts.get("does-not-exist")
|
|
331
|
+
rescue OmniSocials::NotFoundError => e
|
|
332
|
+
puts "Gone: #{e.code} #{e.message}"
|
|
333
|
+
rescue OmniSocials::RateLimitError => e
|
|
334
|
+
puts "Slow down, retry in #{e.retry_after}s"
|
|
335
|
+
rescue OmniSocials::ValidationError => e
|
|
336
|
+
puts "Bad request: #{e.body}"
|
|
337
|
+
rescue OmniSocials::AuthenticationError
|
|
338
|
+
puts "Check your API key"
|
|
339
|
+
rescue OmniSocials::APIConnectionError
|
|
340
|
+
puts "Network problem"
|
|
341
|
+
rescue OmniSocials::APIError => e
|
|
342
|
+
puts "API error: #{e.status} #{e.message}"
|
|
343
|
+
end
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The full hierarchy: `OmniSocials::Error` is the base; `OmniSocials::APIError` covers all HTTP errors with subclasses `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429, with `#retry_after`), and `ServerError` (5xx); `APIConnectionError` covers network failures; `WebhookVerificationError` covers signature failures.
|
|
347
|
+
|
|
348
|
+
## Rate limits
|
|
349
|
+
|
|
350
|
+
The API allows **100 requests per minute** per API key. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. On 429 the SDK automatically waits for the `Retry-After` value and retries. When syncing analytics for many posts, prefer the bulk `client.analytics.posts([...])` endpoint (100 posts per request) over per-post calls.
|
|
351
|
+
|
|
352
|
+
## Retries and timeouts
|
|
353
|
+
|
|
354
|
+
The SDK automatically retries on 429, 5xx, and connection errors with exponential backoff (0.5s, 1s, 2s, with jitter), honoring the `Retry-After` header when present. Other 4xx errors are never retried. Defaults: 2 retries, 30 second timeout. Both are configurable:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
client = OmniSocials::Client.new(
|
|
358
|
+
api_key: "omsk_live_...",
|
|
359
|
+
timeout: 60, # seconds
|
|
360
|
+
max_retries: 5, # retries after the first attempt
|
|
361
|
+
base_url: "https://api.omnisocials.com/v1" # override for testing
|
|
362
|
+
)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## More
|
|
366
|
+
|
|
367
|
+
- Full API reference and guides: [https://docs.omnisocials.com](https://docs.omnisocials.com)
|
|
368
|
+
- MCP server for AI agents: `npx -y @omnisocials/mcp-server`
|
|
369
|
+
- Agent skills CLI: `npx skills add OmniSocials/omnisocials-agent-skills`
|
|
370
|
+
|
|
371
|
+
## License
|
|
372
|
+
|
|
373
|
+
MIT
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module OmniSocials
|
|
11
|
+
# Synchronous client for the OmniSocials Public API.
|
|
12
|
+
#
|
|
13
|
+
# require "omnisocials"
|
|
14
|
+
#
|
|
15
|
+
# client = OmniSocials::Client.new # reads OMNISOCIALS_API_KEY from env
|
|
16
|
+
# # or: OmniSocials::Client.new(api_key: "omsk_live_...", base_url: ...,
|
|
17
|
+
# # timeout: ..., max_retries: ...)
|
|
18
|
+
#
|
|
19
|
+
# post = client.posts.create(
|
|
20
|
+
# content: "Hello from the SDK",
|
|
21
|
+
# channels: ["instagram", "linkedin"],
|
|
22
|
+
# scheduled_at: "2026-08-01T09:00:00Z"
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# Zero runtime dependencies: built on Net::HTTP, OpenSSL, and JSON from the
|
|
26
|
+
# Ruby standard library. A fresh connection is opened per request, so there
|
|
27
|
+
# is nothing to close.
|
|
28
|
+
class Client
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.omnisocials.com/v1"
|
|
30
|
+
DEFAULT_TIMEOUT = 30.0
|
|
31
|
+
DEFAULT_MAX_RETRIES = 2
|
|
32
|
+
USER_AGENT = "omnisocials-ruby/#{VERSION}"
|
|
33
|
+
|
|
34
|
+
# Statuses that are retried automatically (alongside connection errors).
|
|
35
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
36
|
+
|
|
37
|
+
METHOD_CLASSES = {
|
|
38
|
+
"GET" => Net::HTTP::Get,
|
|
39
|
+
"POST" => Net::HTTP::Post,
|
|
40
|
+
"PATCH" => Net::HTTP::Patch,
|
|
41
|
+
"DELETE" => Net::HTTP::Delete
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
CONNECTION_ERRORS = [
|
|
45
|
+
Errno::ECONNREFUSED,
|
|
46
|
+
Errno::ECONNRESET,
|
|
47
|
+
Errno::EHOSTUNREACH,
|
|
48
|
+
Errno::ENETUNREACH,
|
|
49
|
+
Errno::EPIPE,
|
|
50
|
+
Errno::ETIMEDOUT,
|
|
51
|
+
SocketError,
|
|
52
|
+
EOFError,
|
|
53
|
+
IOError,
|
|
54
|
+
Net::OpenTimeout,
|
|
55
|
+
Net::ReadTimeout,
|
|
56
|
+
Net::WriteTimeout,
|
|
57
|
+
OpenSSL::SSL::SSLError,
|
|
58
|
+
Timeout::Error
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
attr_reader :api_key, :base_url, :timeout, :max_retries,
|
|
62
|
+
:posts, :media, :folders, :accounts, :analytics, :locations,
|
|
63
|
+
:webhooks
|
|
64
|
+
|
|
65
|
+
# api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
|
|
66
|
+
# OMNISOCIALS_API_KEY environment variable. Raises
|
|
67
|
+
# OmniSocials::AuthenticationError when neither is set.
|
|
68
|
+
# base_url - API base URL (default https://api.omnisocials.com/v1).
|
|
69
|
+
# timeout - per-request timeout in seconds (default 30).
|
|
70
|
+
# max_retries - automatic retries after the first attempt (default 2),
|
|
71
|
+
# applied on 429, 5xx, and connection errors.
|
|
72
|
+
def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT,
|
|
73
|
+
max_retries: DEFAULT_MAX_RETRIES)
|
|
74
|
+
key = api_key.nil? || api_key.to_s.empty? ? ENV["OMNISOCIALS_API_KEY"] : api_key
|
|
75
|
+
if key.nil? || key.to_s.empty?
|
|
76
|
+
raise AuthenticationError.new(
|
|
77
|
+
"No API key provided. Pass api_key: ... to OmniSocials::Client.new " \
|
|
78
|
+
"or set the OMNISOCIALS_API_KEY environment variable. Create a key " \
|
|
79
|
+
"in the OmniSocials app under Settings -> API Keys.",
|
|
80
|
+
status: nil
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
@api_key = key
|
|
84
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
85
|
+
@timeout = Float(timeout)
|
|
86
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
87
|
+
|
|
88
|
+
@posts = Resources::Posts.new(self)
|
|
89
|
+
@media = Resources::Media.new(self)
|
|
90
|
+
@folders = Resources::Folders.new(self)
|
|
91
|
+
@accounts = Resources::Accounts.new(self)
|
|
92
|
+
@analytics = Resources::Analytics.new(self)
|
|
93
|
+
@locations = Resources::Locations.new(self)
|
|
94
|
+
@webhooks = Resources::Webhooks.new(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# GET /health - API health check (no scopes required).
|
|
98
|
+
def health
|
|
99
|
+
request("GET", "/health")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Perform an HTTP request against the API with automatic retries.
|
|
103
|
+
#
|
|
104
|
+
# Returns the parsed response body (Hash/Array) as-is, the raw text for
|
|
105
|
+
# non-JSON responses, or nil for 204. Raises an OmniSocials::APIError
|
|
106
|
+
# subclass on non-2xx and OmniSocials::APIConnectionError when no
|
|
107
|
+
# response was ever received.
|
|
108
|
+
def request(method, path, query: nil, json: nil, multipart: nil)
|
|
109
|
+
uri = build_uri(path, query)
|
|
110
|
+
last_error = nil
|
|
111
|
+
|
|
112
|
+
(max_retries + 1).times do |attempt|
|
|
113
|
+
begin
|
|
114
|
+
response = perform(method, uri, json: json, multipart: multipart)
|
|
115
|
+
rescue *CONNECTION_ERRORS => e
|
|
116
|
+
last_error = e
|
|
117
|
+
break if attempt >= max_retries
|
|
118
|
+
|
|
119
|
+
sleep(Internal.backoff_delay(attempt, nil))
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
should_retry, retry_after = retry_info(response)
|
|
124
|
+
if should_retry && attempt < max_retries
|
|
125
|
+
sleep(Internal.backoff_delay(attempt, retry_after))
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
return handle_response(response)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
raise APIConnectionError,
|
|
132
|
+
"Connection error while requesting #{method} #{uri}: " \
|
|
133
|
+
"#{last_error.class}: #{last_error.message}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def build_uri(path, query)
|
|
139
|
+
uri = URI.parse("#{base_url}#{path}")
|
|
140
|
+
pairs = Internal.serialize_query(query)
|
|
141
|
+
uri.query = URI.encode_www_form(pairs) if pairs
|
|
142
|
+
uri
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def perform(method, uri, json:, multipart:)
|
|
146
|
+
request_class = METHOD_CLASSES.fetch(method) do
|
|
147
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
148
|
+
end
|
|
149
|
+
req = request_class.new(uri)
|
|
150
|
+
req["Authorization"] = "Bearer #{api_key}"
|
|
151
|
+
req["User-Agent"] = USER_AGENT
|
|
152
|
+
req["Accept"] = "application/json"
|
|
153
|
+
|
|
154
|
+
if json
|
|
155
|
+
req["Content-Type"] = "application/json"
|
|
156
|
+
req.body = JSON.generate(json)
|
|
157
|
+
elsif multipart
|
|
158
|
+
boundary = "----OmniSocialsRuby#{SecureRandom.hex(16)}"
|
|
159
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
160
|
+
req.body = Internal.multipart_body(
|
|
161
|
+
boundary, multipart[:fields] || {}, multipart[:filename], multipart[:data]
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
166
|
+
http.use_ssl = uri.scheme == "https"
|
|
167
|
+
http.open_timeout = timeout
|
|
168
|
+
http.read_timeout = timeout
|
|
169
|
+
http.write_timeout = timeout
|
|
170
|
+
http.start { |conn| conn.request(req) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def retry_info(response)
|
|
174
|
+
status = response.code.to_i
|
|
175
|
+
if RETRY_STATUSES.include?(status) || status >= 500
|
|
176
|
+
[true, parse_retry_after(response, Internal.parse_body(response.body))]
|
|
177
|
+
else
|
|
178
|
+
[false, nil]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_response(response)
|
|
183
|
+
status = response.code.to_i
|
|
184
|
+
return nil if status == 204
|
|
185
|
+
return Internal.parse_body(response.body) if status >= 200 && status < 300
|
|
186
|
+
|
|
187
|
+
raise error_from_response(response)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def error_from_response(response)
|
|
191
|
+
body = Internal.parse_body(response.body)
|
|
192
|
+
status = response.code.to_i
|
|
193
|
+
|
|
194
|
+
code = nil
|
|
195
|
+
message = "Request failed with status #{status}."
|
|
196
|
+
if body.is_a?(Hash)
|
|
197
|
+
error = body["error"]
|
|
198
|
+
if error.is_a?(Hash)
|
|
199
|
+
code = error["code"] if error["code"].is_a?(String)
|
|
200
|
+
message = error["message"] if error["message"].is_a?(String)
|
|
201
|
+
elsif body["message"].is_a?(String)
|
|
202
|
+
message = body["message"]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
case status
|
|
207
|
+
when 401
|
|
208
|
+
AuthenticationError.new(message, status: status, code: code, body: body)
|
|
209
|
+
when 403
|
|
210
|
+
PermissionDeniedError.new(message, status: status, code: code, body: body)
|
|
211
|
+
when 404
|
|
212
|
+
NotFoundError.new(message, status: status, code: code, body: body)
|
|
213
|
+
when 400, 422
|
|
214
|
+
ValidationError.new(message, status: status, code: code, body: body)
|
|
215
|
+
when 429
|
|
216
|
+
RateLimitError.new(
|
|
217
|
+
message,
|
|
218
|
+
status: status, code: code, body: body,
|
|
219
|
+
retry_after: parse_retry_after(response, body)
|
|
220
|
+
)
|
|
221
|
+
else
|
|
222
|
+
if status >= 500
|
|
223
|
+
ServerError.new(message, status: status, code: code, body: body)
|
|
224
|
+
else
|
|
225
|
+
APIError.new(message, status: status, code: code, body: body)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_retry_after(response, body)
|
|
231
|
+
header = response["retry-after"]
|
|
232
|
+
if header
|
|
233
|
+
stripped = header.strip
|
|
234
|
+
return [stripped.to_f, 0.0].max if stripped.match?(/\A-?\d+(\.\d+)?\z/)
|
|
235
|
+
|
|
236
|
+
begin
|
|
237
|
+
return [Time.httpdate(stripped) - Time.now, 0.0].max
|
|
238
|
+
rescue ArgumentError
|
|
239
|
+
# Not an HTTP-date either; fall through to the body.
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
if body.is_a?(Hash) && body["retry_after"].is_a?(Numeric)
|
|
243
|
+
return [body["retry_after"].to_f, 0.0].max
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
nil
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|