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.
@@ -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