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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
# Error hierarchy for the OmniSocials SDK.
|
|
5
|
+
#
|
|
6
|
+
# OmniSocials::Error (base)
|
|
7
|
+
# OmniSocials::APIError (has #status, #code, #message, #body)
|
|
8
|
+
# OmniSocials::AuthenticationError (401)
|
|
9
|
+
# OmniSocials::PermissionDeniedError (403)
|
|
10
|
+
# OmniSocials::NotFoundError (404)
|
|
11
|
+
# OmniSocials::ValidationError (400 / 422)
|
|
12
|
+
# OmniSocials::RateLimitError (429, exposes #retry_after seconds when known)
|
|
13
|
+
# OmniSocials::ServerError (>= 500)
|
|
14
|
+
# OmniSocials::APIConnectionError (network failure / timeout)
|
|
15
|
+
# OmniSocials::WebhookVerificationError
|
|
16
|
+
|
|
17
|
+
# Base class for every error raised by this SDK.
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
|
|
20
|
+
# A non-2xx response from the OmniSocials API.
|
|
21
|
+
#
|
|
22
|
+
# Attributes:
|
|
23
|
+
# status - HTTP status code (nil for client-side failures such as a
|
|
24
|
+
# missing API key at construction time).
|
|
25
|
+
# code - machine-readable error code from the API body
|
|
26
|
+
# ({"error" => {"code" => ..., "message" => ...}}) when present.
|
|
27
|
+
# body - the parsed response body (Hash) when the response was JSON,
|
|
28
|
+
# otherwise the raw response text, or nil.
|
|
29
|
+
class APIError < Error
|
|
30
|
+
attr_reader :status, :code, :body
|
|
31
|
+
|
|
32
|
+
def initialize(message, status: nil, code: nil, body: nil)
|
|
33
|
+
super(message)
|
|
34
|
+
@status = status
|
|
35
|
+
@code = code
|
|
36
|
+
@body = body
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 401 - missing or invalid API key.
|
|
41
|
+
class AuthenticationError < APIError; end
|
|
42
|
+
|
|
43
|
+
# 403 - the API key lacks the required scope.
|
|
44
|
+
class PermissionDeniedError < APIError; end
|
|
45
|
+
|
|
46
|
+
# 404 - the requested resource does not exist.
|
|
47
|
+
class NotFoundError < APIError; end
|
|
48
|
+
|
|
49
|
+
# 400 / 422 - the request was malformed or failed validation.
|
|
50
|
+
class ValidationError < APIError; end
|
|
51
|
+
|
|
52
|
+
# 429 - rate limit exceeded (100 requests per minute).
|
|
53
|
+
#
|
|
54
|
+
# #retry_after is the number of seconds to wait before retrying, taken from
|
|
55
|
+
# the Retry-After header (or the body's retry_after field) when present.
|
|
56
|
+
class RateLimitError < APIError
|
|
57
|
+
attr_reader :retry_after
|
|
58
|
+
|
|
59
|
+
def initialize(message, status: 429, code: nil, body: nil, retry_after: nil)
|
|
60
|
+
super(message, status: status, code: code, body: body)
|
|
61
|
+
@retry_after = retry_after
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# >= 500 - something went wrong on OmniSocials' side.
|
|
66
|
+
class ServerError < APIError; end
|
|
67
|
+
|
|
68
|
+
# The request never got a response (network failure or timeout).
|
|
69
|
+
class APIConnectionError < Error
|
|
70
|
+
def initialize(message = "Connection error.")
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Webhook signature verification failed.
|
|
76
|
+
class WebhookVerificationError < Error; end
|
|
77
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module OmniSocials
|
|
7
|
+
# Sentinel distinguishing "omitted" from an explicit nil.
|
|
8
|
+
#
|
|
9
|
+
# A few endpoints treat null as meaningful (for example
|
|
10
|
+
# media.update(folder_id: nil) moves a file to the root folder), so nil
|
|
11
|
+
# cannot double as "not provided" there. Use OmniSocials::NOT_GIVEN as the
|
|
12
|
+
# default for those parameters.
|
|
13
|
+
class NotGiven
|
|
14
|
+
def inspect
|
|
15
|
+
"NOT_GIVEN"
|
|
16
|
+
end
|
|
17
|
+
alias to_s inspect
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
NOT_GIVEN = NotGiven.new.freeze
|
|
21
|
+
|
|
22
|
+
# Internal helpers shared by the client core and the resource classes.
|
|
23
|
+
# Not part of the public API.
|
|
24
|
+
module Internal
|
|
25
|
+
MAX_BACKOFF_SECONDS = 8.0
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Drop nil and NOT_GIVEN values (top level only).
|
|
29
|
+
#
|
|
30
|
+
# Nested values are passed through untouched, so platform option hashes
|
|
31
|
+
# can still carry meaningful nils (e.g. x: { "thread_parts" => nil } on
|
|
32
|
+
# update clears an X thread).
|
|
33
|
+
def drop_nil(hash)
|
|
34
|
+
hash.reject { |_key, value| value.nil? || value.is_a?(NotGiven) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Join an array into a comma-separated string; pass strings through.
|
|
38
|
+
def join_list(value)
|
|
39
|
+
return nil if value.nil?
|
|
40
|
+
return value if value.is_a?(String)
|
|
41
|
+
|
|
42
|
+
value.map(&:to_s).join(",")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stringify query params, dropping nil/NOT_GIVEN values.
|
|
46
|
+
def serialize_query(params)
|
|
47
|
+
return nil if params.nil? || params.empty?
|
|
48
|
+
|
|
49
|
+
out = {}
|
|
50
|
+
params.each do |key, value|
|
|
51
|
+
next if value.nil? || value.is_a?(NotGiven)
|
|
52
|
+
|
|
53
|
+
out[key.to_s] =
|
|
54
|
+
case value
|
|
55
|
+
when true then "true"
|
|
56
|
+
when false then "false"
|
|
57
|
+
else value.to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
out.empty? ? nil : out
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Parse a response body as JSON, falling back to the raw text.
|
|
64
|
+
def parse_body(raw)
|
|
65
|
+
return nil if raw.nil? || raw.empty?
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
JSON.parse(raw)
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
raw
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Delay before retry number +attempt+ (0-based): 0.5s, 1s, 2s... + jitter.
|
|
75
|
+
#
|
|
76
|
+
# An explicit Retry-After from the server wins over the computed backoff.
|
|
77
|
+
def backoff_delay(attempt, retry_after)
|
|
78
|
+
return [retry_after, 60.0].min unless retry_after.nil?
|
|
79
|
+
|
|
80
|
+
delay = [0.5 * (2**attempt), MAX_BACKOFF_SECONDS].min
|
|
81
|
+
delay * (0.75 + rand * 0.5)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Normalize the +file+ argument to [filename, binary_data].
|
|
85
|
+
#
|
|
86
|
+
# Accepts:
|
|
87
|
+
# - an IO / File / StringIO (anything responding to #read)
|
|
88
|
+
# - a file path (a String or Pathname)
|
|
89
|
+
# - raw bytes (a binary-encoded String, e.g. from File.binread)
|
|
90
|
+
#
|
|
91
|
+
# The content is read fully into memory so automatic retries can resend it.
|
|
92
|
+
def coerce_file(file, filename)
|
|
93
|
+
case file
|
|
94
|
+
when Pathname
|
|
95
|
+
path = file.to_s
|
|
96
|
+
return [filename || File.basename(path), File.binread(path)]
|
|
97
|
+
when String
|
|
98
|
+
# A binary-encoded String is treated as raw bytes; any other String
|
|
99
|
+
# is treated as a file path (mirrors the Python SDK where str = path).
|
|
100
|
+
return [filename || "upload", file] if file.encoding == Encoding::BINARY
|
|
101
|
+
|
|
102
|
+
return [filename || File.basename(file), File.binread(file)]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if file.respond_to?(:read)
|
|
106
|
+
data = file.read.to_s.b
|
|
107
|
+
inferred = file.respond_to?(:path) ? file.path : nil
|
|
108
|
+
resolved = filename
|
|
109
|
+
resolved ||= File.basename(inferred) if inferred.is_a?(String) && !inferred.empty?
|
|
110
|
+
return [resolved || "upload", data]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"file must be an IO, a file path (String or Pathname), or raw bytes " \
|
|
115
|
+
"(a binary-encoded String, e.g. from File.binread); got #{file.class}."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build a multipart/form-data body (single file part named "file" plus
|
|
119
|
+
# optional plain form fields), hand-rolled so the SDK stays dependency-free.
|
|
120
|
+
def multipart_body(boundary, fields, filename, data)
|
|
121
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
122
|
+
fields.each do |name, value|
|
|
123
|
+
body << "--#{boundary}\r\n".b
|
|
124
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n\r\n".b
|
|
125
|
+
body << value.to_s.b
|
|
126
|
+
body << "\r\n".b
|
|
127
|
+
end
|
|
128
|
+
safe_name = filename.to_s.gsub('"', "%22").gsub(/[\r\n]/, " ")
|
|
129
|
+
body << "--#{boundary}\r\n".b
|
|
130
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{safe_name}\"\r\n".b
|
|
131
|
+
body << "Content-Type: application/octet-stream\r\n\r\n".b
|
|
132
|
+
body << data.to_s.b
|
|
133
|
+
body << "\r\n--#{boundary}--\r\n".b
|
|
134
|
+
body
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Accounts resource: connected social accounts for the API key's workspace.
|
|
6
|
+
class Accounts
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /accounts - list connected social accounts.
|
|
12
|
+
#
|
|
13
|
+
# The response also carries workspace_id and workspace_name for the
|
|
14
|
+
# workspace the API key belongs to.
|
|
15
|
+
def list
|
|
16
|
+
@client.request("GET", "/accounts")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /accounts/{id} - fetch a single connected account.
|
|
20
|
+
def get(account_id)
|
|
21
|
+
@client.request("GET", "/accounts/#{account_id}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Analytics resource: post stats, account stats, overview, and best times.
|
|
6
|
+
class Analytics
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /analytics/posts/{id} - latest per-platform metrics for one post.
|
|
12
|
+
def post(post_id)
|
|
13
|
+
@client.request("GET", "/analytics/posts/#{post_id}")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# GET /analytics/posts?ids=a,b,c - bulk metrics for up to 100 posts in
|
|
17
|
+
# one request. `ids` is an Array of post ids (or an already-joined
|
|
18
|
+
# comma-separated String).
|
|
19
|
+
def posts(ids)
|
|
20
|
+
@client.request(
|
|
21
|
+
"GET", "/analytics/posts",
|
|
22
|
+
query: { "ids" => Internal.join_list(ids) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# GET /analytics/overview - workspace-wide analytics overview.
|
|
27
|
+
def overview(period: nil, start_date: nil, end_date: nil)
|
|
28
|
+
@client.request(
|
|
29
|
+
"GET", "/analytics/overview",
|
|
30
|
+
query: { "period" => period, "start_date" => start_date, "end_date" => end_date }
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /analytics/accounts - account-level stats (followers etc.).
|
|
35
|
+
def accounts(platform: nil, date: nil)
|
|
36
|
+
@client.request(
|
|
37
|
+
"GET", "/analytics/accounts",
|
|
38
|
+
query: { "platform" => platform, "date" => date }
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# GET /analytics/best-times - best times to post for a platform,
|
|
43
|
+
# derived from the workspace's own engagement history.
|
|
44
|
+
def best_times(platform:, timezone: nil)
|
|
45
|
+
@client.request(
|
|
46
|
+
"GET", "/analytics/best-times",
|
|
47
|
+
query: { "platform" => platform, "timezone" => timezone }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Folders resource: organize media library files into folders.
|
|
6
|
+
class Folders
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /folders - list media folders (with per-folder file counts).
|
|
12
|
+
def list
|
|
13
|
+
@client.request("GET", "/folders")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# POST /folders - create a folder (optionally nested under parent_id).
|
|
17
|
+
def create(name:, parent_id: nil)
|
|
18
|
+
body = Internal.drop_nil({ "name" => name, "parent_id" => parent_id })
|
|
19
|
+
@client.request("POST", "/folders", json: body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# PATCH /folders/{id} - rename and/or move a folder.
|
|
23
|
+
#
|
|
24
|
+
# Pass parent_id: nil explicitly to move the folder to the top level;
|
|
25
|
+
# omit it to leave the parent unchanged.
|
|
26
|
+
def update(folder_id, name: nil, parent_id: OmniSocials::NOT_GIVEN)
|
|
27
|
+
body = Internal.drop_nil({ "name" => name })
|
|
28
|
+
body["parent_id"] = parent_id unless parent_id.is_a?(NotGiven)
|
|
29
|
+
@client.request("PATCH", "/folders/#{folder_id}", json: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# DELETE /folders/{id} - delete a folder. Returns nil (204).
|
|
33
|
+
#
|
|
34
|
+
# The folder's media is NOT deleted: files move to the root and
|
|
35
|
+
# subfolders move up to the deleted folder's parent.
|
|
36
|
+
def delete(folder_id)
|
|
37
|
+
@client.request("DELETE", "/folders/#{folder_id}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Locations resource: Instagram place tagging (search + validate).
|
|
6
|
+
class Locations
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /locations/search?q= - search Facebook place pages usable as an
|
|
12
|
+
# Instagram location_id.
|
|
13
|
+
def search(q)
|
|
14
|
+
@client.request("GET", "/locations/search", query: { "q" => q })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET /locations/validate?id= - validate a location id before attaching
|
|
18
|
+
# it to a post.
|
|
19
|
+
def validate(id)
|
|
20
|
+
@client.request("GET", "/locations/validate", query: { "id" => id })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Media resource: upload, list, check, rename/move, and delete library files.
|
|
6
|
+
class Media
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# GET /media - list library files.
|
|
12
|
+
def list(limit: nil, offset: nil, search: nil, folder_id: nil)
|
|
13
|
+
@client.request(
|
|
14
|
+
"GET", "/media",
|
|
15
|
+
query: {
|
|
16
|
+
"limit" => limit,
|
|
17
|
+
"offset" => offset,
|
|
18
|
+
"search" => search,
|
|
19
|
+
"folder_id" => folder_id
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /media/{id} - fetch a single library file.
|
|
25
|
+
def get(media_id)
|
|
26
|
+
@client.request("GET", "/media/#{media_id}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /media/upload - multipart upload (max 100MB inline).
|
|
30
|
+
#
|
|
31
|
+
# `file` is an IO, a file path (String or Pathname), or raw bytes (a
|
|
32
|
+
# binary-encoded String, e.g. from File.binread).
|
|
33
|
+
#
|
|
34
|
+
# A PDF is split into image slides and the response carries `slides`
|
|
35
|
+
# plus a `media_ids` array instead of a single `data` item. For files
|
|
36
|
+
# over 100MB use upload_from_url (up to 1GB) or the create_upload_url
|
|
37
|
+
# presigned flow.
|
|
38
|
+
def upload(file:, filename: nil, name: nil, folder: nil, folder_id: nil)
|
|
39
|
+
upload_name, data = Internal.coerce_file(file, filename)
|
|
40
|
+
fields = Internal.drop_nil(
|
|
41
|
+
{ "name" => name, "folder" => folder, "folder_id" => folder_id }
|
|
42
|
+
)
|
|
43
|
+
@client.request(
|
|
44
|
+
"POST", "/media/upload",
|
|
45
|
+
multipart: { fields: fields, filename: upload_name, data: data }
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# POST /media/upload-from-url - server-side fetch, up to 1GB.
|
|
50
|
+
#
|
|
51
|
+
# Files over 100MB are streamed in the background: the response has
|
|
52
|
+
# data["status"] == "processing"; poll get() until it is "ready".
|
|
53
|
+
def upload_from_url(url:, filename: nil, name: nil, folder: nil, folder_id: nil)
|
|
54
|
+
body = Internal.drop_nil(
|
|
55
|
+
{
|
|
56
|
+
"url" => url,
|
|
57
|
+
"filename" => filename,
|
|
58
|
+
"name" => name,
|
|
59
|
+
"folder" => folder,
|
|
60
|
+
"folder_id" => folder_id
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
@client.request("POST", "/media/upload-from-url", json: body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# POST /media/upload-from-base64 - upload base64-encoded data (without
|
|
67
|
+
# a data URI prefix).
|
|
68
|
+
def upload_from_base64(data:, mime_type:, filename: nil, name: nil,
|
|
69
|
+
folder: nil, folder_id: nil)
|
|
70
|
+
body = Internal.drop_nil(
|
|
71
|
+
{
|
|
72
|
+
"data" => data,
|
|
73
|
+
"mime_type" => mime_type,
|
|
74
|
+
"filename" => filename,
|
|
75
|
+
"name" => name,
|
|
76
|
+
"folder" => folder,
|
|
77
|
+
"folder_id" => folder_id
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
@client.request("POST", "/media/upload-from-base64", json: body)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# POST /media/create-upload-url - mint a one-time presigned URL.
|
|
84
|
+
#
|
|
85
|
+
# Returns {"upload_url", "upload_token", "expires_in_seconds", ...}.
|
|
86
|
+
# POST the file to upload_url as multipart form data (field name "file")
|
|
87
|
+
# with NO auth headers; the token in the URL authenticates the single
|
|
88
|
+
# upload and expires after 10 minutes.
|
|
89
|
+
def create_upload_url
|
|
90
|
+
@client.request("POST", "/media/create-upload-url")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# POST /media/check - preflight platform compatibility.
|
|
94
|
+
#
|
|
95
|
+
# Provide one of: a public `url`, an existing `media_id`, or
|
|
96
|
+
# `size_bytes` + `mime`.
|
|
97
|
+
def check(url: nil, media_id: nil, size_bytes: nil, mime: nil)
|
|
98
|
+
body = Internal.drop_nil(
|
|
99
|
+
{
|
|
100
|
+
"url" => url,
|
|
101
|
+
"media_id" => media_id,
|
|
102
|
+
"size_bytes" => size_bytes,
|
|
103
|
+
"mime" => mime
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
@client.request("POST", "/media/check", json: body)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# PATCH /media/{id} - rename and/or move a file.
|
|
110
|
+
#
|
|
111
|
+
# Pass folder_id: nil explicitly to move the file to the root
|
|
112
|
+
# ("All media"); omit it to leave the folder unchanged.
|
|
113
|
+
def update(media_id, name: nil, folder_id: OmniSocials::NOT_GIVEN)
|
|
114
|
+
body = Internal.drop_nil({ "name" => name })
|
|
115
|
+
body["folder_id"] = folder_id unless folder_id.is_a?(NotGiven)
|
|
116
|
+
@client.request("PATCH", "/media/#{media_id}", json: body)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# DELETE /media/{id} - delete a file. Returns nil (204).
|
|
120
|
+
#
|
|
121
|
+
# Fails with 409 media_in_use if the file is attached to a scheduled or
|
|
122
|
+
# publishing post.
|
|
123
|
+
def delete(media_id)
|
|
124
|
+
@client.request("DELETE", "/media/#{media_id}")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniSocials
|
|
4
|
+
module Resources
|
|
5
|
+
# Posts resource: create, schedule, publish, update, and list posts.
|
|
6
|
+
#
|
|
7
|
+
# `content` is a plain String, or a per-platform Hash with a "default" key.
|
|
8
|
+
# `media_ids` / `media_urls` are a flat Array, or a per-platform Hash.
|
|
9
|
+
class Posts
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# GET /posts - list posts (status: draft, scheduled, posted, failed).
|
|
15
|
+
def list(status: nil, limit: nil, offset: nil)
|
|
16
|
+
@client.request(
|
|
17
|
+
"GET", "/posts",
|
|
18
|
+
query: { "status" => status, "limit" => limit, "offset" => offset }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# GET /posts/{id} - fetch a single post.
|
|
23
|
+
def get(post_id)
|
|
24
|
+
@client.request("GET", "/posts/#{post_id}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /posts/recent-platform - recent posts fetched live from the
|
|
28
|
+
# connected platform APIs (including content published outside
|
|
29
|
+
# OmniSocials). Requires the analytics:read scope.
|
|
30
|
+
def recent_platform(limit: nil, platforms: nil)
|
|
31
|
+
@client.request(
|
|
32
|
+
"GET", "/posts/recent-platform",
|
|
33
|
+
query: { "limit" => limit, "platforms" => Internal.join_list(platforms) }
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# POST /posts/create - create a post (draft, or scheduled when
|
|
38
|
+
# scheduled_at is set).
|
|
39
|
+
def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
|
|
40
|
+
media_urls: nil, type: nil, source: nil, link_url: nil,
|
|
41
|
+
link_title: nil, link_description: nil, link_thumbnail_url: nil,
|
|
42
|
+
location_id: nil, collaborators: nil, user_tags: nil,
|
|
43
|
+
pinterest: nil, youtube: nil, instagram: nil, facebook: nil,
|
|
44
|
+
linkedin: nil, linkedin_page: nil, tiktok: nil, x: nil,
|
|
45
|
+
bluesky: nil, mastodon: nil, google_business: nil)
|
|
46
|
+
body = create_body(
|
|
47
|
+
content: content, channels: channels, scheduled_at: scheduled_at,
|
|
48
|
+
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
49
|
+
source: source, link_url: link_url, link_title: link_title,
|
|
50
|
+
link_description: link_description, link_thumbnail_url: link_thumbnail_url,
|
|
51
|
+
location_id: location_id, collaborators: collaborators,
|
|
52
|
+
user_tags: user_tags, pinterest: pinterest, youtube: youtube,
|
|
53
|
+
instagram: instagram, facebook: facebook, linkedin: linkedin,
|
|
54
|
+
linkedin_page: linkedin_page, tiktok: tiktok, x: x, bluesky: bluesky,
|
|
55
|
+
mastodon: mastodon, google_business: google_business
|
|
56
|
+
)
|
|
57
|
+
@client.request("POST", "/posts/create", json: body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# POST /posts/create-and-publish - create and publish immediately.
|
|
61
|
+
def create_and_publish(content:, channels: nil, media_ids: nil,
|
|
62
|
+
media_urls: nil, type: nil, source: nil,
|
|
63
|
+
link_url: nil, link_title: nil, link_description: nil,
|
|
64
|
+
link_thumbnail_url: nil, location_id: nil,
|
|
65
|
+
collaborators: nil, user_tags: nil, pinterest: nil,
|
|
66
|
+
youtube: nil, instagram: nil, facebook: nil,
|
|
67
|
+
linkedin: nil, linkedin_page: nil, tiktok: nil,
|
|
68
|
+
x: nil, bluesky: nil, mastodon: nil,
|
|
69
|
+
google_business: nil)
|
|
70
|
+
body = create_body(
|
|
71
|
+
content: content, channels: channels, scheduled_at: nil,
|
|
72
|
+
media_ids: media_ids, media_urls: media_urls, type: type,
|
|
73
|
+
source: source, link_url: link_url, link_title: link_title,
|
|
74
|
+
link_description: link_description, link_thumbnail_url: link_thumbnail_url,
|
|
75
|
+
location_id: location_id, collaborators: collaborators,
|
|
76
|
+
user_tags: user_tags, pinterest: pinterest, youtube: youtube,
|
|
77
|
+
instagram: instagram, facebook: facebook, linkedin: linkedin,
|
|
78
|
+
linkedin_page: linkedin_page, tiktok: tiktok, x: x, bluesky: bluesky,
|
|
79
|
+
mastodon: mastodon, google_business: google_business
|
|
80
|
+
)
|
|
81
|
+
@client.request("POST", "/posts/create-and-publish", json: body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# PATCH /posts/{id} - update a draft or scheduled post.
|
|
85
|
+
#
|
|
86
|
+
# Only top-level nils are dropped from the body, so passing e.g.
|
|
87
|
+
# x: { "thread_parts" => nil } still clears an X thread (reverts the
|
|
88
|
+
# post to single-tweet mode). The same applies to bluesky and mastodon
|
|
89
|
+
# thread parts.
|
|
90
|
+
def update(post_id, content: nil, scheduled_at: nil, channels: nil,
|
|
91
|
+
media_ids: nil, media_urls: nil, type: nil, location_id: nil,
|
|
92
|
+
collaborators: nil, user_tags: nil, pinterest: nil,
|
|
93
|
+
youtube: nil, instagram: nil, facebook: nil, linkedin: nil,
|
|
94
|
+
linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
|
|
95
|
+
mastodon: nil, google_business: nil)
|
|
96
|
+
body = Internal.drop_nil(
|
|
97
|
+
{
|
|
98
|
+
"content" => content,
|
|
99
|
+
"scheduled_at" => scheduled_at,
|
|
100
|
+
"channels" => channels,
|
|
101
|
+
"media_ids" => media_ids,
|
|
102
|
+
"media_urls" => media_urls,
|
|
103
|
+
"type" => type,
|
|
104
|
+
"location_id" => location_id,
|
|
105
|
+
"collaborators" => collaborators,
|
|
106
|
+
"user_tags" => user_tags,
|
|
107
|
+
"pinterest" => pinterest,
|
|
108
|
+
"youtube" => youtube,
|
|
109
|
+
"instagram" => instagram,
|
|
110
|
+
"facebook" => facebook,
|
|
111
|
+
"linkedin" => linkedin,
|
|
112
|
+
"linkedin_page" => linkedin_page,
|
|
113
|
+
"tiktok" => tiktok,
|
|
114
|
+
"x" => x,
|
|
115
|
+
"bluesky" => bluesky,
|
|
116
|
+
"mastodon" => mastodon,
|
|
117
|
+
"google_business" => google_business
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
@client.request("PATCH", "/posts/#{post_id}", json: body)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# DELETE /posts/{id} - delete a post. Returns nil (204).
|
|
124
|
+
def delete(post_id)
|
|
125
|
+
@client.request("DELETE", "/posts/#{post_id}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# POST /posts/{id}/publish - publish a draft or scheduled post now.
|
|
129
|
+
def publish(post_id)
|
|
130
|
+
@client.request("POST", "/posts/#{post_id}/publish")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def create_body(content:, channels:, scheduled_at:, media_ids:,
|
|
136
|
+
media_urls:, type:, source:, link_url:, link_title:,
|
|
137
|
+
link_description:, link_thumbnail_url:, location_id:,
|
|
138
|
+
collaborators:, user_tags:, pinterest:, youtube:,
|
|
139
|
+
instagram:, facebook:, linkedin:, linkedin_page:,
|
|
140
|
+
tiktok:, x:, bluesky:, mastodon:, google_business:)
|
|
141
|
+
Internal.drop_nil(
|
|
142
|
+
{
|
|
143
|
+
"content" => content,
|
|
144
|
+
"channels" => channels,
|
|
145
|
+
"scheduled_at" => scheduled_at,
|
|
146
|
+
"media_ids" => media_ids,
|
|
147
|
+
"media_urls" => media_urls,
|
|
148
|
+
"type" => type,
|
|
149
|
+
"source" => source,
|
|
150
|
+
"link_url" => link_url,
|
|
151
|
+
"link_title" => link_title,
|
|
152
|
+
"link_description" => link_description,
|
|
153
|
+
"link_thumbnail_url" => link_thumbnail_url,
|
|
154
|
+
"location_id" => location_id,
|
|
155
|
+
"collaborators" => collaborators,
|
|
156
|
+
"user_tags" => user_tags,
|
|
157
|
+
"pinterest" => pinterest,
|
|
158
|
+
"youtube" => youtube,
|
|
159
|
+
"instagram" => instagram,
|
|
160
|
+
"facebook" => facebook,
|
|
161
|
+
"linkedin" => linkedin,
|
|
162
|
+
"linkedin_page" => linkedin_page,
|
|
163
|
+
"tiktok" => tiktok,
|
|
164
|
+
"x" => x,
|
|
165
|
+
"bluesky" => bluesky,
|
|
166
|
+
"mastodon" => mastodon,
|
|
167
|
+
"google_business" => google_business
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|