omnisocials 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8be79cfb8525cbb823f24a4a34d6cb3d51b193d864db80cc190bc0a7db45a18
4
- data.tar.gz: ed95b24b6e722f29e9c7e94e9d4506c69dece3579f9e609966fa999d5d1893bf
3
+ metadata.gz: 4b3fed727cadc18b6ea190937598241ea06e5ac3451f5a01e47e993f641f5332
4
+ data.tar.gz: 6adeeba6ddc69911b79c4b620ddc418e0dd372d6e76e3a417bd285ae54b44e9d
5
5
  SHA512:
6
- metadata.gz: 2138c0350aea5e6c3059bf5d71bc9cd050441ac11b8846d01d55f9c6bd88c83488cd4e47b5c17ba8e298f98c01d75e53dcf1e54845a89a2c92553f4e896a9386
7
- data.tar.gz: 10177239235e7dcd623615ed273c4171b81c2819de790bdc636ff495a1dd3b177cc18011b4cf9ac67a1162358d711d927d38a07c7a00ed9d672a0fe551dc524d
6
+ metadata.gz: ad4c11a0f91aa25cd0dfab676f0a28b5973d139a82137c0d753de697a453d710bd9f59f6c212596230fe727e37d20e117635d7b23d06f2f6aeeefce3d66b5ffa
7
+ data.tar.gz: 1b53388871737de150a7d71387472915b3e1bfcc7c8e245f45e5c3d87ea4ef2ee9d8047855876d33e1028c0b24272accfe663ef482e83bbdc4e60a72fe9268af
data/README.md CHANGED
@@ -103,9 +103,9 @@ post = client.posts.create(
103
103
 
104
104
  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
105
 
106
- ### X thread example
106
+ ### Chained threads (X, Bluesky, Mastodon, Threads)
107
107
 
108
- 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:
108
+ 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):
109
109
 
110
110
  ```ruby
111
111
  post = client.posts.create(
@@ -123,7 +123,24 @@ post = client.posts.create(
123
123
  )
124
124
  ```
125
125
 
126
- 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`.
126
+ ```ruby
127
+ # Meta Threads chain with a carousel on the first part
128
+ post = client.posts.create(
129
+ content: "Behind the scenes of our summer shoot",
130
+ channels: ["threads"],
131
+ threads: {
132
+ "thread_parts" => [
133
+ { "text" => "Behind the scenes of our summer shoot. A few highlights:", "media_urls" => ["https://example.com/shoot-1.jpg", "https://example.com/shoot-2.jpg"] },
134
+ { "text" => "Day one: scouting locations at sunrise." },
135
+ { "text" => "Day two: the full crew, 14 hours, zero regrets." }
136
+ ]
137
+ }
138
+ )
139
+ ```
140
+
141
+ On update, passing `x: { "thread_parts" => nil }` clears the thread and reverts the post to single-tweet mode (same for `bluesky`, `mastodon` and `threads`). Only top-level `nil` values are dropped from request bodies, so nested `nil` values like this one are sent as JSON `null`.
142
+
143
+ Threads posts can also carry a location tag: pass `threads: { "location_id" => "..." }` with an id from `client.locations.search(platform: "threads")` (see Locations below). On a multi-post thread the tag is applied to part 1, and on update `threads: { "location_id" => nil }` clears it. Threads location tagging is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error.
127
144
 
128
145
  ### X link posts use credits
129
146
 
@@ -182,6 +199,15 @@ recent = client.posts.recent_platform(limit: 10, platforms: ["instagram", "tikto
182
199
 
183
200
  `retry` re-publishes only the platforms that failed, on the same post; platforms that already succeeded are never posted again. It is asynchronous: a 200 means the retry is queued, so poll `get` for the outcome. Max 3 retries per platform.
184
201
 
202
+ ### Approve or reject a post
203
+
204
+ ```ruby
205
+ client.posts.approve("123") # approve the current approval-workflow step
206
+ client.posts.reject("123", comment: "Wrong CTA link, please fix.") # reject and stop the workflow (comment optional)
207
+ ```
208
+
209
+ Only works on a post with `approval_status: "pending"` (`status: "in_approval"`). Both act on behalf of the user who owns the API key, who must be a listed approver for the workflow's CURRENT step — steps approve in order, so being an approver on a later step is not enough yet (raises a 403 `OmniSocials::PermissionDeniedError`). Approving the last step finalizes the post (`scheduled` or `posting`); rejecting stops the whole workflow immediately, not just the current step.
210
+
185
211
  ## Media
186
212
 
187
213
  ### Upload from a URL (recommended, up to 1GB)
@@ -315,7 +341,7 @@ best = client.analytics.best_times(platform: "instagram", timezone: "Europe/Amst
315
341
  best["data"]["best_times"].each { |slot| puts slot }
316
342
  ```
317
343
 
318
- ## Locations (Instagram place tagging)
344
+ ## Locations (Instagram and Threads place tagging)
319
345
 
320
346
  ```ruby
321
347
  results = client.locations.search("Blue Bottle Coffee Oakland")
@@ -330,6 +356,23 @@ client.posts.create(
330
356
  )
331
357
  ```
332
358
 
359
+ Threads uses its own location ids (a Facebook Place ID is not a Threads location id). Pass `platform: "threads"` and search by keyword, or by `latitude` plus `longitude` instead of `q`; use a result's `id` as `threads.location_id` on a post:
360
+
361
+ ```ruby
362
+ results = client.locations.search("Blue Bottle Coffee Oakland", platform: "threads")
363
+ # or around a point instead of a keyword:
364
+ results = client.locations.search(platform: "threads", latitude: 37.8044, longitude: -122.2712)
365
+ threads_location_id = results["locations"][0]["id"]
366
+
367
+ client.posts.create(
368
+ content: "Great coffee here",
369
+ channels: ["threads"],
370
+ threads: { "location_id" => threads_location_id }
371
+ )
372
+ ```
373
+
374
+ 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.
375
+
333
376
  ## Inbox
334
377
 
335
378
  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`.
@@ -348,9 +391,30 @@ end
348
391
 
349
392
  client.inbox.mark_read(conversation_id)
350
393
  client.inbox.reply(conversation_id, text: "Thanks for reaching out!")
394
+
395
+ # Hide or unhide a comment someone left on one of your posts (Facebook,
396
+ # Instagram, TikTok, YouTube, Threads). Delete removes it outright (Facebook,
397
+ # Instagram, TikTok; YouTube: hide instead), replies under it included.
398
+ message_id = messages["data"][0]["id"]
399
+ client.inbox.hide(message_id) # hide
400
+ client.inbox.hide(message_id, hide: false) # unhide
401
+ client.inbox.delete_message(message_id)
351
402
  ```
352
403
 
353
- `platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, or `"x"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. TikTok replies are comments only and capped at 150 characters. Conversation ids are URL-encoded for you, so pass them exactly as returned - LinkedIn ids contain `":"` and `"()"` (e.g. `"linkedin_comment_urn:li:activity:123"`).
404
+ `platform` accepts `"instagram"`, `"facebook"`, `"linkedin"`, `"tiktok"`, `"youtube"`, `"x"`, or `"threads"`; `type` accepts `"dm"`, `"comment"`, or `"mention"`. Threads conversations are comments (replies people leave on your Threads posts) and mentions; there are no Threads DMs. Comments can be hidden on Facebook, Instagram, TikTok, YouTube, and Threads (Threads: incoming top-level replies only), and a hidden message keeps its place in the conversation with its `hidden` flag set; `hidden` is `true`/`false` on comments and `nil` on DMs. A comment/mention's `post` carries `url` (public link when the platform provides one) and `media_type` (the platform's own label) next to `id`, `caption`, and `thumbnail`. Threads inbox is currently rolling out; until Meta approves the permissions it is disabled on production and calls return a clear error, and it needs a Threads connection with the reply permission (a 401 `reauth_required` means reconnect Threads). TikTok and YouTube replies are comments only; TikTok replies are capped at 150 characters. Conversation ids are URL-encoded for you, so pass them exactly as returned - LinkedIn ids contain `":"` and `"()"` (e.g. `"linkedin_comment_urn:li:activity:123"`).
405
+
406
+ `next` hands out the next conversation that still needs a reply (the customer's latest DM with no reply after it, or an unreplied comment/mention that is not hidden), with the whole thread and the post it belongs to, so a reply can be drafted from one call. Replies typed in the native apps count as answers. Only unread items are served by default, so `mark_read` is the durable way to skip one; `exclude` skips conversation ids for the current session only. Pass `include_next: true` to `reply` to get the following item in the same response. `list_conversations(unanswered: true)` gives the same set as a plain list.
407
+
408
+ ```ruby
409
+ item = client.inbox.next(platform: "instagram")
410
+ while item["data"]
411
+ message = item["data"]["message"]
412
+ puts "#{item["remaining"]} left. #{message["sender"]["username"]}: #{message["text"]}"
413
+
414
+ reply = client.inbox.reply(message["conversation_id"], text: "Thanks! DM sent.", include_next: true)
415
+ item = { "data" => reply["next"], "remaining" => reply["remaining"] || 0 }
416
+ end
417
+ ```
354
418
 
355
419
  Replying to an X DM costs 2 prepaid credits, debited from the company balance before the send and automatically refunded if the send fails:
356
420
 
@@ -59,8 +59,8 @@ module OmniSocials
59
59
  ].freeze
60
60
 
61
61
  attr_reader :api_key, :base_url, :timeout, :max_retries,
62
- :posts, :media, :folders, :hashtag_sets, :accounts, :analytics,
63
- :audio, :locations, :inbox, :webhooks
62
+ :posts, :media, :folders, :hashtag_sets, :approval_workflows,
63
+ :accounts, :analytics, :audio, :locations, :inbox, :webhooks
64
64
 
65
65
  # api_key - API key (omsk_live_* / omsk_test_*). Falls back to the
66
66
  # OMNISOCIALS_API_KEY environment variable. Raises
@@ -89,6 +89,7 @@ module OmniSocials
89
89
  @media = Resources::Media.new(self)
90
90
  @folders = Resources::Folders.new(self)
91
91
  @hashtag_sets = Resources::HashtagSets.new(self)
92
+ @approval_workflows = Resources::ApprovalWorkflows.new(self)
92
93
  @accounts = Resources::Accounts.new(self)
93
94
  @analytics = Resources::Analytics.new(self)
94
95
  @audio = Resources::Audio.new(self)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniSocials
4
+ module Resources
5
+ # Approval workflows resource: the workflows configured in the dashboard
6
+ # (Approvals). List them here and route a post through one at create time
7
+ # via approval_workflow_id on posts.create.
8
+ class ApprovalWorkflows
9
+ def initialize(client)
10
+ @client = client
11
+ end
12
+
13
+ # GET /approval-workflows - the workflows this workspace can use
14
+ # (company-wide plus workspace-bound), with steps and named approvers.
15
+ def list
16
+ @client.request("GET", "/approval-workflows")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -23,17 +23,30 @@ module OmniSocials
23
23
  # GET /inbox/conversations - list conversations, newest activity first.
24
24
  #
25
25
  # All filters are optional: platform ("instagram", "facebook",
26
- # "linkedin", "tiktok", "x"), type ("dm", "comment", "mention"),
27
- # unread (only conversations with unread messages), limit (1-100), and
28
- # cursor (an opaque cursor from a previous response's
29
- # pagination["next_cursor"]).
30
- def list_conversations(platform: nil, type: nil, unread: nil, limit: nil, cursor: nil)
26
+ # "linkedin", "tiktok", "youtube", "x", "threads"), type ("dm", "comment",
27
+ # "mention"), unread (only conversations with unread messages),
28
+ # unanswered (only conversations that still need an answer: the
29
+ # customer's latest DM has no reply after it, for Instagram/Facebook DMs
30
+ # within the 24-hour messaging window only, or a comment/mention that
31
+ # has not been replied to and is not hidden; replies typed in the native
32
+ # apps count as answers, and read state is ignored, so use `next` for a
33
+ # work queue), limit (1-100), and cursor (an opaque cursor from a
34
+ # previous response's pagination["next_cursor"]).
35
+ #
36
+ # Threads conversations are type "comment" (replies people leave on the
37
+ # user's Threads posts; conversation ids look like
38
+ # "threads_comment_<rootPostId>") and "mention"
39
+ # ("threads_mention_<postId>"); there are no Threads DMs. Threads inbox
40
+ # needs a Threads connection with the reply permissions; connections made
41
+ # before those permissions existed must be reconnected once.
42
+ def list_conversations(platform: nil, type: nil, unread: nil, unanswered: nil, limit: nil, cursor: nil)
31
43
  @client.request(
32
44
  "GET", "/inbox/conversations",
33
45
  query: {
34
46
  "platform" => platform,
35
47
  "type" => type,
36
48
  "unread" => unread,
49
+ "unanswered" => unanswered,
37
50
  "limit" => limit,
38
51
  "cursor" => cursor
39
52
  }
@@ -58,9 +71,16 @@ module OmniSocials
58
71
  # POST /inbox/conversations/{id}/reply - send a reply into the
59
72
  # conversation (a DM message, or a reply to the comment/mention).
60
73
  #
61
- # `text` is required. Optionally attach a single media asset by public
62
- # URL with `attachment_url` plus `attachment_type` ("image", "video",
63
- # "audio", or "file"). Returns the created outgoing message.
74
+ # On Facebook and Instagram DMs, optionally attach a single media asset
75
+ # by public URL with `attachment_url` plus `attachment_type` ("image",
76
+ # "video", "audio", or "file"); `text` is optional when `attachment_url`
77
+ # is set (an attachment-only reply is allowed). Other platforms are
78
+ # text-only. Returns the created outgoing message.
79
+ #
80
+ # On a Threads conversation the reply publishes as a native Threads
81
+ # reply. The Threads inbox needs a Threads connection with the reply
82
+ # permission: a 401 with code "reauth_required" means the connection
83
+ # lacks that permission (connected before it existed; reconnect Threads).
64
84
  #
65
85
  # Replying to an X DM costs 2 prepaid credits, debited from the
66
86
  # company balance before the send and automatically refunded if the
@@ -70,12 +90,19 @@ module OmniSocials
70
90
  # auto-suspended after the balance hit zero (top up and re-enable it
71
91
  # in the dashboard to resume; DMs that arrived while suspended are
72
92
  # not recovered).
73
- def reply(conversation_id, text:, attachment_url: nil, attachment_type: nil)
93
+ #
94
+ # Pass include_next: true to also get "next" (the next conversation
95
+ # that needs an answer, the same object `next` returns under "data",
96
+ # using its default queue order and filters; nil when nothing is
97
+ # waiting) and "remaining" in the response. Saves the extra call when
98
+ # working through the inbox.
99
+ def reply(conversation_id, text: nil, attachment_url: nil, attachment_type: nil, include_next: nil)
74
100
  body = Internal.drop_nil(
75
101
  {
76
102
  "text" => text,
77
103
  "attachment_url" => attachment_url,
78
- "attachment_type" => attachment_type
104
+ "attachment_type" => attachment_type,
105
+ "include_next" => include_next
79
106
  }
80
107
  )
81
108
  @client.request(
@@ -84,11 +111,113 @@ module OmniSocials
84
111
  )
85
112
  end
86
113
 
114
+ # POST /inbox/messages/{id}/hide - hide or unhide a comment someone
115
+ # left on one of the user's posts, on the platform, as the post owner.
116
+ # Facebook, Instagram, TikTok, YouTube and Threads comments (Threads:
117
+ # incoming top-level replies only; Threads does not allow hiding nested
118
+ # replies). Pass hide: false to unhide. On YouTube, hide sets the
119
+ # comment's moderation status to rejected, which removes it and its
120
+ # replies from public view; unhide publishes it again. Returns the
121
+ # updated message with its "hidden" flag flipped; the message keeps its
122
+ # place in the conversation, and a hidden comment no longer counts as
123
+ # unanswered. The account must have been connected with the moderation
124
+ # permission (Facebook pages_manage_engagement, Instagram
125
+ # instagram_business_manage_comments).
126
+ #
127
+ # Errors: 400 "unsupported_platform" (not an incoming comment on a
128
+ # supported platform), 400 "not_hideable" (Threads nested reply, or
129
+ # Threads refused), 401 "reauth_required" (the Threads reply permission
130
+ # or the TikTok comments authorization is missing or expired), 403
131
+ # "reconnect_required" (the account was connected without the
132
+ # comment-moderation permission; reconnect it in the dashboard), 404
133
+ # "not_found" (message not in this workspace) or
134
+ # "account_not_connected", 429 "quota_exceeded" (YouTube's daily API
135
+ # quota is used up; retry after midnight Pacific), 502 "platform_error"
136
+ # (the platform rejected the call). Threads inbox is currently rolling
137
+ # out; until Meta approves the permissions it is disabled on production
138
+ # and Threads calls return a clear error.
139
+ def hide(message_id, hide: true)
140
+ @client.request(
141
+ "POST", "/inbox/messages/#{encode_id(message_id)}/hide",
142
+ json: { "hide" => hide }
143
+ )
144
+ end
145
+
146
+ # DELETE /inbox/messages/{id} - delete a comment someone left on one of
147
+ # the user's posts, on the platform and from the inbox. Facebook,
148
+ # Instagram and TikTok comments only: YouTube's API does not let a
149
+ # channel delete other people's comments, hide those instead (`hide`).
150
+ # Replies under the deleted comment go with it (the platforms cascade
151
+ # the delete and the inbox mirrors that); their inbox ids come back as
152
+ # "removed_reply_ids". A comment that is already gone on the platform
153
+ # is still removed from the inbox. This cannot be undone. Returns
154
+ # { "data" => { "id", "conversation_id", "removed_reply_ids" } }.
155
+ #
156
+ # Errors: 400 "unsupported_platform" (not an incoming Facebook,
157
+ # Instagram or TikTok comment), 401 "reauth_required" (the TikTok
158
+ # comments authorization expired), 403 "reconnect_required" (the
159
+ # account was connected without the comment-moderation permission;
160
+ # reconnect it in the dashboard), 404 "not_found" (message not in this
161
+ # workspace) or "account_not_connected", 502 "platform_error" (the
162
+ # platform rejected the call).
163
+ def delete_message(message_id)
164
+ @client.request("DELETE", "/inbox/messages/#{encode_id(message_id)}")
165
+ end
166
+
167
+ # GET /inbox/next - the next conversation that needs an answer: a work
168
+ # queue for answering the inbox. Returns the oldest (by default) item
169
+ # that still needs a reply, together with its conversation so far and
170
+ # the post it belongs to, so a reply can be drafted from one call. An
171
+ # item needs an answer when it is the customer's latest DM with no
172
+ # reply after it (Instagram/Facebook DMs within the 24-hour messaging
173
+ # window only, since Meta refuses replies outside it), or a
174
+ # comment/mention that has not been replied to and is not hidden.
175
+ # Replies typed in the native apps count as answers (they are mirrored
176
+ # into the inbox), so a thread a colleague answered on their phone is
177
+ # not served again. Instagram mentions are skipped (no reply path).
178
+ # Looks at the last 30 days of activity. Requires the inbox:read scope.
179
+ #
180
+ # Only unread items are served by default: marking a conversation read
181
+ # (`mark_read`) is how to skip one for good; pass include_read: true to
182
+ # include read-but-unanswered items. exclude is a session-local skip:
183
+ # conversation ids (an Array, or a comma-separated String) to leave out
184
+ # of this call, up to 100. order is "oldest" (default: the item that
185
+ # has waited longest first) or "newest". platform and type ("dm",
186
+ # "comment", "mention") narrow the queue.
187
+ #
188
+ # Returns { "data" => ..., "remaining" => Integer }. "data" is
189
+ # { "conversation", "message", "messages" }, or nil when nothing is
190
+ # waiting. "message" is the unanswered incoming item itself (the
191
+ # customer's latest DM, or the specific comment): its "id" is what
192
+ # `hide` and `delete_message` take, its "conversation_id" is what
193
+ # `reply` takes. "messages" is the conversation so far, oldest first
194
+ # (the most recent 50 messages for long DM threads). "remaining" is the
195
+ # number of unanswered items still waiting after this one (capped at
196
+ # 500), 0 when "data" is nil. To chain the queue, pass
197
+ # include_next: true to `reply` and it returns the next item in the
198
+ # same response. Errors: 400 "validation_error" (unknown platform, type
199
+ # or order).
200
+ def next(platform: nil, type: nil, order: nil, include_read: nil, exclude: nil)
201
+ exclude = exclude.join(",") if exclude.is_a?(Array)
202
+ exclude = nil if exclude == ""
203
+ @client.request(
204
+ "GET", "/inbox/next",
205
+ query: {
206
+ "platform" => platform,
207
+ "type" => type,
208
+ "order" => order,
209
+ "include_read" => include_read,
210
+ "exclude" => exclude
211
+ }
212
+ )
213
+ end
214
+
87
215
  private
88
216
 
89
- # URL-encode a conversation id for use in a path segment. LinkedIn ids
90
- # contain ":" and "()", so they must be escaped; spaces become %20
91
- # (a path segment treats "+" literally, unlike a query string).
217
+ # URL-encode a conversation or message id for use in a path segment.
218
+ # LinkedIn conversation ids contain ":" and "()", so they must be
219
+ # escaped; spaces become %20 (a path segment treats "+" literally,
220
+ # unlike a query string).
92
221
  def encode_id(conversation_id)
93
222
  CGI.escape(conversation_id.to_s).gsub("+", "%20")
94
223
  end
@@ -2,16 +2,48 @@
2
2
 
3
3
  module OmniSocials
4
4
  module Resources
5
- # Locations resource: Instagram place tagging (search + validate).
5
+ # Locations resource: Instagram and Threads place tagging (search +
6
+ # validate).
6
7
  class Locations
7
8
  def initialize(client)
8
9
  @client = client
9
10
  end
10
11
 
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 })
12
+ # GET /locations/search - search locations for place tagging.
13
+ #
14
+ # platform is "instagram" (default) or "threads". The two sources use
15
+ # DIFFERENT ids: a Facebook Place ID is not a Threads location id.
16
+ #
17
+ # Instagram: pass q to search Facebook place pages usable as an
18
+ # Instagram location_id. Response: { "data" => [...] } plus optional
19
+ # "error" (a plain string on the degraded path) and "needsPermission".
20
+ #
21
+ # Threads: pass q, OR latitude (-90..90) plus longitude (-180..180) to
22
+ # search around a point instead of q. Response:
23
+ # { "locations" => [{ id, name, address, city, country, latitude,
24
+ # longitude }] } (all fields but id nullable), or
25
+ # { "error" => { "code", "message" } } where code is one of
26
+ # "not_available" (Threads location tagging not enabled in this
27
+ # environment yet), "threads_not_connected", "threads_reauth_required"
28
+ # (the connection lacks the threads_location_tagging permission;
29
+ # reconnect Threads), or "platform_error". Validation problems (neither
30
+ # q nor lat+lng, q under 2 chars, coordinates out of range) raise a 400
31
+ # with the standard error envelope. Pass a result's id as
32
+ # threads.location_id on post create/update.
33
+ #
34
+ # Threads location tagging is currently rolling out: until Meta
35
+ # approves the permissions it is disabled on production and calls
36
+ # return a clear error.
37
+ def search(q = nil, platform: nil, latitude: nil, longitude: nil)
38
+ @client.request(
39
+ "GET", "/locations/search",
40
+ query: {
41
+ "q" => q,
42
+ "platform" => platform,
43
+ "latitude" => latitude,
44
+ "longitude" => longitude
45
+ }
46
+ )
15
47
  end
16
48
 
17
49
  # GET /locations/validate?id= - validate a location id before attaching
@@ -11,8 +11,8 @@ module OmniSocials
11
11
  # for media_urls, `{ "id" => "...", "alt" => "..." }` for media_ids. Alt
12
12
  # text is delivered to Mastodon (media description), Bluesky (embed alt),
13
13
  # X (photos/GIFs), Pinterest (pin alt text), Instagram (images), and
14
- # LinkedIn (images); the same entry shape works inside x/bluesky/mastodon
15
- # `thread_parts` media.
14
+ # LinkedIn (images); the same entry shape works inside
15
+ # x/bluesky/mastodon/threads `thread_parts` media.
16
16
  class Posts
17
17
  def initialize(client)
18
18
  @client = client
@@ -46,6 +46,13 @@ module OmniSocials
46
46
  # POST /posts/create - create a post (draft, or scheduled when
47
47
  # scheduled_at is set).
48
48
  #
49
+ # approval_workflow_id (a workflow id from client.approval_workflows.list)
50
+ # routes the post through a saved approval workflow: it is created as
51
+ # in_approval (approval_status "pending") instead of scheduled, the
52
+ # approvers are notified, and it publishes at scheduled_at once the last
53
+ # step approves. Requires scheduled_at; not allowed with publish_now.
54
+ # Errors: 404 workflow_not_found, 400 validation_error.
55
+ #
49
56
  # hashtag_set (set name, case-insensitive) or hashtag_set_id applies a
50
57
  # saved hashtag set once at create time; tags already in a caption are
51
58
  # skipped; Instagram's 30-hashtag cap returns error code
@@ -67,6 +74,18 @@ module OmniSocials
67
74
  # company's total reserved credits past its balance. Drafts are never
68
75
  # gated, and posts scheduled to publish before 2026-08-14 are never
69
76
  # gated either.
77
+ #
78
+ # Threads posts can carry a location tag: pass
79
+ # threads: { "location_id" => "..." } (an id from locations.search with
80
+ # platform: "threads"), or threads: { "location" => { "id" => "...",
81
+ # "name" => "..." } } to store display fields along with the id
82
+ # (location_id wins when both are given). On a multi-post thread
83
+ # (thread_parts) the tag is applied to part 1, and the Post's "threads"
84
+ # block echoes a "location" object when set. Threads location tagging
85
+ # is currently rolling out: until Meta approves the permissions it is
86
+ # disabled on production and create/update/publish return a 400 (also a
87
+ # 400 validation_error asking you to reconnect Threads when the
88
+ # connection lacks the threads_location_tagging permission).
70
89
  def create(content:, channels: nil, scheduled_at: nil, media_ids: nil,
71
90
  media_urls: nil, type: nil, source: nil, link_url: nil,
72
91
  link_title: nil, link_description: nil, link_thumbnail_url: nil,
@@ -75,7 +94,8 @@ module OmniSocials
75
94
  hashtag_platforms: nil, pinterest: nil, youtube: nil,
76
95
  instagram: nil, facebook: nil, linkedin: nil,
77
96
  linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
78
- mastodon: nil, google_business: nil, linkedin_poll: nil)
97
+ mastodon: nil, threads: nil, google_business: nil,
98
+ linkedin_poll: nil, approval_workflow_id: nil)
79
99
  body = create_body(
80
100
  content: content, channels: channels, scheduled_at: scheduled_at,
81
101
  media_ids: media_ids, media_urls: media_urls, type: type,
@@ -87,8 +107,9 @@ module OmniSocials
87
107
  hashtag_platforms: hashtag_platforms, pinterest: pinterest,
88
108
  youtube: youtube, instagram: instagram, facebook: facebook,
89
109
  linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
90
- x: x, bluesky: bluesky, mastodon: mastodon,
91
- google_business: google_business, linkedin_poll: linkedin_poll
110
+ x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
111
+ google_business: google_business, linkedin_poll: linkedin_poll,
112
+ approval_workflow_id: approval_workflow_id
92
113
  )
93
114
  @client.request("POST", "/posts/create", json: body)
94
115
  end
@@ -106,7 +127,8 @@ module OmniSocials
106
127
  pinterest: nil, youtube: nil, instagram: nil,
107
128
  facebook: nil, linkedin: nil, linkedin_page: nil,
108
129
  tiktok: nil, x: nil, bluesky: nil, mastodon: nil,
109
- google_business: nil, linkedin_poll: nil)
130
+ threads: nil, google_business: nil,
131
+ linkedin_poll: nil)
110
132
  body = create_body(
111
133
  content: content, channels: channels, scheduled_at: nil,
112
134
  media_ids: media_ids, media_urls: media_urls, type: type,
@@ -118,7 +140,7 @@ module OmniSocials
118
140
  hashtag_platforms: hashtag_platforms, pinterest: pinterest,
119
141
  youtube: youtube, instagram: instagram, facebook: facebook,
120
142
  linkedin: linkedin, linkedin_page: linkedin_page, tiktok: tiktok,
121
- x: x, bluesky: bluesky, mastodon: mastodon,
143
+ x: x, bluesky: bluesky, mastodon: mastodon, threads: threads,
122
144
  google_business: google_business, linkedin_poll: linkedin_poll
123
145
  )
124
146
  @client.request("POST", "/posts/create-and-publish", json: body)
@@ -128,8 +150,9 @@ module OmniSocials
128
150
  #
129
151
  # Only top-level nils are dropped from the body, so passing e.g.
130
152
  # x: { "thread_parts" => nil } still clears an X thread (reverts the
131
- # post to single-tweet mode). The same applies to bluesky and mastodon
132
- # thread parts.
153
+ # post to single-tweet mode). The same applies to bluesky, mastodon
154
+ # and threads thread parts, and to a Threads location tag:
155
+ # threads: { "location_id" => nil } (or "location" => nil) clears it.
133
156
  #
134
157
  # See #create for the 402 "x_credits_insufficient" credit gate that
135
158
  # can also refuse an update to a scheduled X link post.
@@ -138,7 +161,8 @@ module OmniSocials
138
161
  collaborators: nil, user_tags: nil, pinterest: nil,
139
162
  youtube: nil, instagram: nil, facebook: nil, linkedin: nil,
140
163
  linkedin_page: nil, tiktok: nil, x: nil, bluesky: nil,
141
- mastodon: nil, google_business: nil, linkedin_poll: nil)
164
+ mastodon: nil, threads: nil, google_business: nil,
165
+ linkedin_poll: nil)
142
166
  body = Internal.drop_nil(
143
167
  {
144
168
  "content" => content,
@@ -160,6 +184,7 @@ module OmniSocials
160
184
  "x" => x,
161
185
  "bluesky" => bluesky,
162
186
  "mastodon" => mastodon,
187
+ "threads" => threads,
163
188
  "google_business" => google_business,
164
189
  "linkedin_poll" => linkedin_poll
165
190
  }
@@ -167,7 +192,7 @@ module OmniSocials
167
192
  @client.request("PATCH", "/posts/#{post_id}", json: body)
168
193
  end
169
194
 
170
- # DELETE /posts/{id} - delete a post. Returns nil (204).
195
+ # DELETE /posts/{id} - remove a post from OmniSocials (the live post stays on the platform). Returns nil (204).
171
196
  def delete(post_id)
172
197
  @client.request("DELETE", "/posts/#{post_id}")
173
198
  end
@@ -190,6 +215,29 @@ module OmniSocials
190
215
  @client.request("POST", "/posts/#{post_id}/retry")
191
216
  end
192
217
 
218
+ # POST /posts/{id}/approve - approve the current step of a post's
219
+ # approval workflow, on behalf of the user who owns this API key. That
220
+ # user must be a listed approver for the workflow's CURRENT step -
221
+ # steps approve in order, so an approver on a later step gets a 403
222
+ # "forbidden" error until earlier steps clear. Only works on a post
223
+ # with approval_status "pending". If this is the last step, the post
224
+ # finalizes immediately ("scheduled" or "posting"); otherwise it stays
225
+ # "in_approval" and the next step's approvers are notified.
226
+ def approve(post_id)
227
+ @client.request("POST", "/posts/#{post_id}/approve")
228
+ end
229
+
230
+ # POST /posts/{id}/reject - reject a post's approval workflow, on
231
+ # behalf of the user who owns this API key. Same approver requirement
232
+ # as #approve. Unlike approval, a rejection stops the WHOLE workflow
233
+ # immediately (not just the current step) - the post's status becomes
234
+ # "rejected". `comment` is optional and, when given, is shown to the
235
+ # requester and other approvers in the post's review thread.
236
+ def reject(post_id, comment: nil)
237
+ body = comment ? { comment: comment } : nil
238
+ @client.request("POST", "/posts/#{post_id}/reject", json: body)
239
+ end
240
+
193
241
  private
194
242
 
195
243
  def create_body(content:, channels:, scheduled_at:, media_ids:,
@@ -199,7 +247,8 @@ module OmniSocials
199
247
  hashtag_set_id:, hashtag_placement:, hashtag_platforms:,
200
248
  pinterest:, youtube:, instagram:, facebook:, linkedin:,
201
249
  linkedin_page:, tiktok:, x:, bluesky:, mastodon:,
202
- google_business:, linkedin_poll:)
250
+ threads:, google_business:, linkedin_poll:,
251
+ approval_workflow_id: nil)
203
252
  Internal.drop_nil(
204
253
  {
205
254
  "content" => content,
@@ -230,8 +279,10 @@ module OmniSocials
230
279
  "x" => x,
231
280
  "bluesky" => bluesky,
232
281
  "mastodon" => mastodon,
282
+ "threads" => threads,
233
283
  "google_business" => google_business,
234
- "linkedin_poll" => linkedin_poll
284
+ "linkedin_poll" => linkedin_poll,
285
+ "approval_workflow_id" => approval_workflow_id
235
286
  }
236
287
  )
237
288
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniSocials
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/omnisocials.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "omnisocials/resources/posts"
20
20
  require_relative "omnisocials/resources/media"
21
21
  require_relative "omnisocials/resources/folders"
22
22
  require_relative "omnisocials/resources/hashtag_sets"
23
+ require_relative "omnisocials/resources/approval_workflows"
23
24
  require_relative "omnisocials/resources/accounts"
24
25
  require_relative "omnisocials/resources/analytics"
25
26
  require_relative "omnisocials/resources/audio"
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.0
4
+ version: 0.6.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-08-22 00:00:00.000000000 Z
11
+ date: 2026-09-17 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,
@@ -27,6 +27,7 @@ files:
27
27
  - lib/omnisocials/internal.rb
28
28
  - lib/omnisocials/resources/accounts.rb
29
29
  - lib/omnisocials/resources/analytics.rb
30
+ - lib/omnisocials/resources/approval_workflows.rb
30
31
  - lib/omnisocials/resources/audio.rb
31
32
  - lib/omnisocials/resources/folders.rb
32
33
  - lib/omnisocials/resources/hashtag_sets.rb