postproxy-sdk 1.14.0 → 1.15.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: f7aef460a3a9d2e2188dc5098adf89fe29e1a917417e965c2234fc23a43019ca
4
- data.tar.gz: 14c146f3c9b8fab34ff17b9a87e4fb4c4464a600b90fe5610bd93a3dc4a9baa1
3
+ metadata.gz: 71cc2ca73303c214ca35568812a214fd9beffe5db6d32cf9f41d59300266262d
4
+ data.tar.gz: c5536be0f5e7dc3a600959ea0168ed13f875cadd5563307f12edc053c70f0147
5
5
  SHA512:
6
- metadata.gz: 4e3e06d85a609de033c1918c55c6388d1ff17fcf604576ee3aec7231e906d2328ffd6f57b6e5747c08ee16f9d5f3b5c04affb99eef012be43fdcd2dcf6e0a1b8
7
- data.tar.gz: 66d7d324060a481a24002ecbaf00238478904e5fdd40916258f1f1aec88e67d8cbfef46d380ab8062304b9364ef3109774834335423a11d0ff56d186c1ffb5b4
6
+ metadata.gz: d236100a3164098822f53d8bcc7d7b3c094a07df5fd250b745465a063c0efe8bc45b1bb6cb2955142fa6c75a235635b2708da200a9cea0f048de88788d61d867
7
+ data.tar.gz: 06f7f18856bf30b06e03aa0afa28ab5ca1ff0b4a0d9e03447a8d755cab139ea2016a085c114428c555c1fc3a257a4d6da223832aca51ce57ee569dd354eff36e
data/README.md CHANGED
@@ -318,8 +318,8 @@ Subscribe to any of these events (or pass `["*"]` for all):
318
318
 
319
319
  - **Posts:** `post.processed`, `post.imported`, `platform_post.published`, `platform_post.failed`, `platform_post.failed_waiting_for_retry`, `platform_post.edited`, `platform_post.edit_failed`, `platform_post.insights`, `media.failed`
320
320
  - **Profiles:** `profile.connected`, `profile.disconnected`, `profile.stats`, `profile.placements_synced`, `placement.orphaned`, `placement.restored`
321
- - **Comments:** `comment.created`, `comment.failed`, `comment.failed_waiting_for_retry`, `comment.delivery_unconfirmed`, `comment.edited`, `comment.edit_failed`, `profile_comment.created`, `profile_comment.failed`, `profile_comment.failed_waiting_for_retry`, `profile_comment.deleted`
322
- - **Messages:** `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.edited`, `message.deleted`, `message.failed_waiting_for_retry`, `message.failed`, `reaction.received`, `referral.received`
321
+ - **Comments:** `comment.created`, `comment.failed`, `comment.failed_waiting_for_retry`, `comment.delivery_unconfirmed`, `comment.edited`, `comment.edit_failed`, `profile_comment.created`, `profile_comment.failed`, `profile_comment.failed_waiting_for_retry`, `profile_comment.edited`, `profile_comment.deleted`
322
+ - **Messages:** `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.edited`, `message.deleted`, `message.failed_waiting_for_retry`, `message.failed`, `reaction.received`, `referral.received`, `chat.thread_control_changed`, `chat.thread_control_requested`
323
323
  - **WhatsApp:** `message_template.status_updated`, `message_template.category_updated`, `placement.name_status_updated`, `placement.quality_updated`, `whatsapp.automatic_event`
324
324
 
325
325
  `PostProxy::WebhookEvents.parse` validates the envelope and returns a typed `Event` — `event.data` is the right model for the event:
@@ -344,13 +344,19 @@ when "profile_comment.created"
344
344
  puts "#{event.data.author_username}: #{event.data.body}"
345
345
  when "platform_post.edited"
346
346
  puts "Edit went live on #{event.data.platform}"
347
+ when "chat.thread_control_changed"
348
+ # ThreadControlChangedEventData
349
+ puts "Chat #{event.data.chat.id} now owned by #{event.data.new_owner_app_id}"
350
+ when "profile.disconnected"
351
+ # ProfileEventData — error / error_details explain an automated disconnect
352
+ puts "#{event.data.name} disconnected (#{event.data.origin}): #{event.data.error}"
347
353
  when "message_template.status_updated"
348
354
  # MessageTemplateEventData
349
355
  puts "#{event.data.name}: #{event.data.status}"
350
356
  end
351
357
  ```
352
358
 
353
- The direct-message events (`message.*`) carry a `MessageEventData` (`data.message` is a full `Message`); `reaction.received` carries a `ReactionEventData`; the `profile_comment.*` events carry a `ProfileCommentCreatedData` and the `comment.*` events a `CommentCreatedData` (with `error` / `error_details` on failures). `platform_post.edited` / `edit_failed` reuse `PlatformPostData`; `profile.placements_synced` carries a `PlacementsSyncedData`, the `placement.*` events a `PlacementEventData`, `referral.received` a `ReferralEventData`, `message_template.*` a `MessageTemplateEventData` and `whatsapp.automatic_event` a `WhatsAppAutomaticEventData`.
359
+ The direct-message events (`message.*`) carry a `MessageEventData` (`data.message` is a full `Message`); `reaction.received` carries a `ReactionEventData`; the `profile_comment.*` events carry a `ProfileCommentCreatedData` and the `comment.*` events a `CommentCreatedData` (with `error` / `error_details` on failures). `platform_post.edited` / `edit_failed` reuse `PlatformPostData`; `profile.placements_synced` carries a `PlacementsSyncedData`, the `placement.*` events a `PlacementEventData`, `referral.received` a `ReferralEventData`, `chat.thread_control_changed` a `ThreadControlChangedEventData` and `chat.thread_control_requested` a `ThreadControlRequestedEventData` (each with `data.chat` as a `WebhookChatData`), `message_template.*` a `MessageTemplateEventData` and `whatsapp.automatic_event` a `WhatsAppAutomaticEventData`.
354
360
 
355
361
  ## Comments
356
362
 
@@ -494,6 +500,7 @@ client.messages.send(chat.id, body: "Following up.", tag: "HUMAN_AGENT")
494
500
  client.messages.send(chat.id, media: ["https://cdn.example.com/photo.png"])
495
501
  client.messages.send(chat.id, media_files: ["./photo.png"])
496
502
 
503
+ # Telegram, WhatsApp & Instagram: quote a message with reply_to_external_id
497
504
  # Telegram: reply threading + inline keyboard
498
505
  client.messages.send(
499
506
  chat.id,
@@ -520,6 +527,14 @@ client.chats.mark_read(chat.id)
520
527
  client.chats.start_typing(chat.id)
521
528
  client.chats.stop_typing(chat.id)
522
529
 
530
+ # Thread control (Facebook & Instagram): move the conversation between Meta apps
531
+ # sharing the inbox. Each returns the chat; metadata[:thread_control] holds the owner.
532
+ client.chats.pass_thread_control(chat.id, target_app_id: "263902037430900", metadata: "escalated to support")
533
+ client.chats.take_thread_control(chat.id)
534
+ client.chats.release_thread_control(chat.id)
535
+ client.chats.request_thread_control(chat.id)
536
+ owner = client.chats.thread_owner(chat.id) # ThreadOwner: app_id, expires_at (both nil when idle)
537
+
523
538
  # Private reply to a comment's author (Instagram/Facebook) — returns a Message
524
539
  message = client.comments.private_reply("post-id", "comment-id", profile_id: "profile-id", text: "DM-ing you the details.")
525
540
  puts message.chat_id, message.status
@@ -743,6 +758,27 @@ audio = client.profiles.instagram_available_audio("ig-profile-id",
743
758
  )
744
759
  puts audio[:data].map { |a| a[:id] }
745
760
 
761
+ # Instagram (Facebook login): incoming collaboration invites
762
+ invites = client.profiles.instagram_collaboration_invites("ig-profile-id") # after: invites.next_cursor
763
+ client.profiles.accept_instagram_collaboration_invite("ig-profile-id", invites.collaboration_invites.first.media_id)
764
+ client.profiles.decline_instagram_collaboration_invite("ig-profile-id", "17890000000000001")
765
+
766
+ # Pinterest boards — a board is a placement; its id is the board_id you publish to
767
+ board = client.profiles.create_pinterest_board("pin-profile-id", "Summer Recipes",
768
+ description: "Light dinners for hot days",
769
+ privacy: "PUBLIC" # or "PROTECTED"
770
+ )
771
+ client.profiles.delete_pinterest_board("pin-profile-id", board.id) # also deletes its Pins
772
+
773
+ # YouTube playlists — publish into one with platforms.youtube.playlist_id
774
+ page = client.profiles.youtube_playlists("yt-profile-id") # page_token: page.next_page_token
775
+ playlist = client.profiles.create_youtube_playlist("yt-profile-id", "Tutorials",
776
+ description: "Step-by-step guides",
777
+ privacy_status: "public"
778
+ )
779
+ client.profiles.update_youtube_playlist("yt-profile-id", playlist.id, privacy_status: "unlisted")
780
+ client.profiles.delete_youtube_playlist("yt-profile-id", playlist.id)
781
+
746
782
  # Facebook Page about / contact info (returned as a hash)
747
783
  page = client.profiles.facebook_page("fb-profile-id", "page-id")
748
784
  client.profiles.update_facebook_page("fb-profile-id", "page-id", {
@@ -991,6 +1027,14 @@ gbp.create_media("gbp-profile-id", "location-id", "https://example.com/storefron
991
1027
  category: "EXTERIOR", media_format: "PHOTO")
992
1028
  media = gbp.media("gbp-profile-id", "location-id")
993
1029
  gbp.delete_media("gbp-profile-id", "location-id", "accounts/1/locations/1/media/9")
1030
+
1031
+ # Search keywords that showed the location, month by month (max 12 months).
1032
+ # Unlike the rest, this returns a typed SearchKeywordsResponse.
1033
+ result = gbp.search_keywords("gbp-profile-id", "location-id", from_month: "2026-07", to_month: "2026-09")
1034
+ result.months.each do |month|
1035
+ # Low-volume keywords have impressions nil and an impressions_threshold bound
1036
+ month.keywords.each { |k| puts "#{month.month} #{k.keyword} #{k.impressions || "<#{k.impressions_threshold}"}" }
1037
+ end
994
1038
  ```
995
1039
 
996
1040
  ## WhatsApp management
@@ -1149,9 +1193,21 @@ client.posts.create("New drop", profiles: ["threads-profile", "tt-profile"],
1149
1193
  client.posts.create("Summer vibes", profiles: ["ig-profile"],
1150
1194
  media: ["https://example.com/reel.mp4"],
1151
1195
  platforms: { instagram: { format: "reel", audio_id: "1234567890", audio_volume: 60, video_volume: 30 } })
1196
+
1197
+ # Instagram: comments off, location tag, reel kept out of the feed; LinkedIn
1198
+ # document title and first comment; YouTube playlist
1199
+ client.posts.create("Behind the scenes", profiles: ["ig-profile", "li-profile", "yt-profile"],
1200
+ media: ["https://example.com/clip.mp4"],
1201
+ platforms: PostProxy::PlatformParams.new(
1202
+ instagram: PostProxy::InstagramParams.new(format: "reel", disable_comment: true,
1203
+ location_id: "110843418940484", share_to_feed: false),
1204
+ linkedin: PostProxy::LinkedInParams.new(first_comment: "Full write-up: https://example.com/bts"),
1205
+ youtube: PostProxy::YouTubeParams.new(title: "Behind the scenes", playlist_id: "PLxxxxxxxx")
1206
+ )
1207
+ )
1152
1208
  ```
1153
1209
 
1154
- `InstagramParams#trial_strategy` takes `"MANUAL"` or `"SS_PERFORMANCE"`.
1210
+ `InstagramParams#trial_strategy` takes `"MANUAL"` or `"SS_PERFORMANCE"`. `disable_comment` and `location_id` (a Facebook Page with an address) apply to posts and reels, `share_to_feed` to reels only. `LinkedInParams#document_title` titles an attached document (defaults to the file name); `first_comment` is not available on polls, and LinkedIn profiles connected before 2026-10-28 must reconnect to use it. `YouTubeParams#playlist_id` comes from `client.profiles.youtube_playlists`.
1155
1211
 
1156
1212
  ### Custom covers
1157
1213
 
@@ -36,6 +36,8 @@ module PostProxy
36
36
 
37
37
  INSTAGRAM_AUDIO_TYPES = %w[music original_sound].freeze
38
38
 
39
+ PINTEREST_BOARD_PRIVACIES = %w[PUBLIC PROTECTED].freeze
40
+
39
41
  # Facebook and Instagram profiles connect through Facebook login; WhatsApp
40
42
  # through the Cloud API or the WhatsApp Business app.
41
43
  CONNECTION_ONBOARDINGS = %w[instagram facebook api business_app].freeze
@@ -77,6 +79,7 @@ module PostProxy
77
79
  profile_comment.created
78
80
  profile_comment.failed
79
81
  profile_comment.failed_waiting_for_retry
82
+ profile_comment.edited
80
83
  profile_comment.deleted
81
84
  message.received
82
85
  message.sent
@@ -88,6 +91,8 @@ module PostProxy
88
91
  message.failed
89
92
  reaction.received
90
93
  referral.received
94
+ chat.thread_control_changed
95
+ chat.thread_control_requested
91
96
  message_template.status_updated
92
97
  message_template.category_updated
93
98
  placement.name_status_updated
@@ -86,8 +86,54 @@ module PostProxy
86
86
  AcceptedResponse.new(**result)
87
87
  end
88
88
 
89
+ # --- Thread control (Facebook & Instagram) ---
90
+ #
91
+ # Meta gives one app at a time control of a conversation when several
92
+ # share a Page or Instagram inbox. These return the chat, whose
93
+ # `metadata[:thread_control]` holds the owner Meta reports back.
94
+
95
+ # Hand the conversation to another Meta app, e.g. Page Inbox
96
+ # `263902037430900` or Instagram Inbox `1217981644879628`.
97
+ def pass_thread_control(chat_id, target_app_id:, metadata: nil, profile_group_id: nil, idempotency_key: nil)
98
+ thread_control(chat_id, "pass_thread_control", { target_app_id: target_app_id },
99
+ metadata, profile_group_id, idempotency_key)
100
+ end
101
+
102
+ # Take the conversation from whichever app holds it. Needs Conversation
103
+ # Routing; Instagram must be connected via Facebook.
104
+ def take_thread_control(chat_id, metadata: nil, profile_group_id: nil, idempotency_key: nil)
105
+ thread_control(chat_id, "take_thread_control", {}, metadata, profile_group_id, idempotency_key)
106
+ end
107
+
108
+ # Give up control; the conversation goes idle.
109
+ def release_thread_control(chat_id, metadata: nil, profile_group_id: nil, idempotency_key: nil)
110
+ thread_control(chat_id, "release_thread_control", {}, metadata, profile_group_id, idempotency_key)
111
+ end
112
+
113
+ # Ask the current owner to pass the conversation. Nothing changes until
114
+ # it does — watch for `chat.thread_control_changed`.
115
+ def request_thread_control(chat_id, metadata: nil, profile_group_id: nil, idempotency_key: nil)
116
+ thread_control(chat_id, "request_thread_control", {}, metadata, profile_group_id, idempotency_key)
117
+ end
118
+
119
+ # Ask Meta who controls the conversation right now.
120
+ def thread_owner(chat_id, profile_group_id: nil)
121
+ result = @client.request(:get, "/chats/#{chat_id}/thread_owner", profile_group_id: profile_group_id)
122
+ ThreadOwner.new(**result)
123
+ end
124
+
89
125
  private
90
126
 
127
+ def thread_control(chat_id, action, json_body, metadata, profile_group_id, idempotency_key)
128
+ json_body[:metadata] = metadata unless metadata.nil?
129
+ result = @client.request(:post, "/chats/#{chat_id}/#{action}",
130
+ json: json_body,
131
+ profile_group_id: profile_group_id,
132
+ idempotency_key: idempotency_key
133
+ )
134
+ Chat.new(**result)
135
+ end
136
+
91
137
  def format_time(value)
92
138
  return value if value.is_a?(String)
93
139
  value.iso8601
@@ -162,6 +162,18 @@ module PostProxy
162
162
  remove(profile_id, "delete_media", location_id, { media_name: media_name }, profile_group_id, idempotency_key)
163
163
  end
164
164
 
165
+ # --- Search keywords ---
166
+
167
+ # The searches that showed the location in Google Search and Maps, month
168
+ # by month. Months are "YYYY-MM"; the range is at most 12 months. Unlike
169
+ # the rest of this resource, the response is PostProxy-shaped
170
+ # (snake_case) and returned as a SearchKeywordsResponse.
171
+ def search_keywords(profile_id, location_id, from_month: nil, to_month: nil, profile_group_id: nil)
172
+ result = read(profile_id, "search_keywords", location_id,
173
+ { from_month: from_month, to_month: to_month }, profile_group_id)
174
+ SearchKeywordsResponse.new(**result)
175
+ end
176
+
165
177
  private
166
178
 
167
179
  def path(profile_id, action)
@@ -32,6 +32,9 @@ module PostProxy
32
32
  # `elements` sends a carousel of up to 10 cards (Facebook and Instagram);
33
33
  # it can't be combined with body, buttons or card.
34
34
  #
35
+ # `reply_to_external_id` (Telegram, WhatsApp and Instagram) is the
36
+ # `external_id` of the message to quote.
37
+ #
35
38
  # WhatsApp: send exactly one of body, media, `template`, `interactive`,
36
39
  # `location` or `contacts`. Outside the 24-hour window only a `template`
37
40
  # gets through. `category: "utility"` sends a text message under the
@@ -100,6 +100,119 @@ module PostProxy
100
100
  )
101
101
  end
102
102
 
103
+ # Posts from other accounts that invited this Instagram profile as a
104
+ # collaborator. Profiles connected via Facebook login only. Pass the
105
+ # returned `next_cursor` as `after:` for the next page.
106
+ def instagram_collaboration_invites(id, after: nil, profile_group_id: nil)
107
+ params = {}
108
+ params[:after] = after if after
109
+
110
+ result = @client.request(:get, "/profiles/#{id}/instagram/collaboration_invites",
111
+ params: params.empty? ? nil : params,
112
+ profile_group_id: profile_group_id
113
+ )
114
+ InstagramCollaborationInvitesResponse.new(**result)
115
+ end
116
+
117
+ def accept_instagram_collaboration_invite(id, media_id, profile_group_id: nil, idempotency_key: nil)
118
+ result = @client.request(:post, "/profiles/#{id}/instagram/accept_collaboration_invite",
119
+ json: { media_id: media_id },
120
+ profile_group_id: profile_group_id,
121
+ idempotency_key: idempotency_key
122
+ )
123
+ InstagramCollaborationInviteResult.new(**result)
124
+ end
125
+
126
+ def decline_instagram_collaboration_invite(id, media_id, profile_group_id: nil, idempotency_key: nil)
127
+ result = @client.request(:post, "/profiles/#{id}/instagram/decline_collaboration_invite",
128
+ json: { media_id: media_id },
129
+ profile_group_id: profile_group_id,
130
+ idempotency_key: idempotency_key
131
+ )
132
+ InstagramCollaborationInviteResult.new(**result)
133
+ end
134
+
135
+ # Pinterest only. Boards are the profile's placements; the new board's
136
+ # `id` is the `board_id` you publish to. `privacy` is "PUBLIC" or
137
+ # "PROTECTED".
138
+ def create_pinterest_board(id, name, description: nil, privacy: nil, profile_group_id: nil,
139
+ idempotency_key: nil)
140
+ json_body = { name: name }
141
+ json_body[:description] = description unless description.nil?
142
+ json_body[:privacy] = privacy unless privacy.nil?
143
+
144
+ result = @client.request(:post, "/profiles/#{id}/pinterest/create_board",
145
+ json: json_body,
146
+ profile_group_id: profile_group_id,
147
+ idempotency_key: idempotency_key
148
+ )
149
+ Placement.new(**result)
150
+ end
151
+
152
+ # Also deletes every Pin on the board, on Pinterest. Cannot be undone.
153
+ def delete_pinterest_board(id, board_id, profile_group_id: nil, idempotency_key: nil)
154
+ result = @client.request(:delete, "/profiles/#{id}/pinterest/delete_board",
155
+ params: { board_id: board_id },
156
+ profile_group_id: profile_group_id,
157
+ idempotency_key: idempotency_key
158
+ )
159
+ PinterestBoardDeleted.new(**result)
160
+ end
161
+
162
+ # YouTube only. Up to 50 playlists per page; pass the returned
163
+ # `next_page_token` as `page_token:` for the next one.
164
+ def youtube_playlists(id, page_token: nil, profile_group_id: nil)
165
+ params = {}
166
+ params[:page_token] = page_token if page_token
167
+
168
+ result = @client.request(:get, "/profiles/#{id}/youtube/playlists",
169
+ params: params.empty? ? nil : params,
170
+ profile_group_id: profile_group_id
171
+ )
172
+ YouTubePlaylistsResponse.new(**result)
173
+ end
174
+
175
+ # `privacy_status` is "public", "unlisted" or "private".
176
+ def create_youtube_playlist(id, title, description: nil, privacy_status: nil, profile_group_id: nil,
177
+ idempotency_key: nil)
178
+ json_body = { title: title }
179
+ json_body[:description] = description unless description.nil?
180
+ json_body[:privacy_status] = privacy_status unless privacy_status.nil?
181
+
182
+ result = @client.request(:post, "/profiles/#{id}/youtube/create_playlist",
183
+ json: json_body,
184
+ profile_group_id: profile_group_id,
185
+ idempotency_key: idempotency_key
186
+ )
187
+ YouTubePlaylist.new(**result)
188
+ end
189
+
190
+ # Fields left out keep their current value; `description: ""` clears it.
191
+ def update_youtube_playlist(id, playlist_id, title: nil, description: nil, privacy_status: nil,
192
+ profile_group_id: nil, idempotency_key: nil)
193
+ json_body = { playlist_id: playlist_id }
194
+ json_body[:title] = title unless title.nil?
195
+ json_body[:description] = description unless description.nil?
196
+ json_body[:privacy_status] = privacy_status unless privacy_status.nil?
197
+
198
+ result = @client.request(:patch, "/profiles/#{id}/youtube/update_playlist",
199
+ json: json_body,
200
+ profile_group_id: profile_group_id,
201
+ idempotency_key: idempotency_key
202
+ )
203
+ YouTubePlaylist.new(**result)
204
+ end
205
+
206
+ # The videos in the playlist are not deleted.
207
+ def delete_youtube_playlist(id, playlist_id, profile_group_id: nil, idempotency_key: nil)
208
+ result = @client.request(:delete, "/profiles/#{id}/youtube/delete_playlist",
209
+ params: { playlist_id: playlist_id },
210
+ profile_group_id: profile_group_id,
211
+ idempotency_key: idempotency_key
212
+ )
213
+ YouTubePlaylistDeleted.new(**result)
214
+ end
215
+
103
216
  # Facebook only. `page_id` is the placement id from #placements. Returns
104
217
  # the Page fields as a hash.
105
218
  def facebook_page(id, page_id, fields: nil, profile_group_id: nil)
@@ -491,7 +491,9 @@ module PostProxy
491
491
  # `external_placement_id` is the page / phone number the chat runs through;
492
492
  # `group` marks a WhatsApp group chat. `within_messaging_window` is
493
493
  # WhatsApp-only: whether the 24-hour customer service window is open —
494
- # when it's closed only a template can be sent.
494
+ # when it's closed only a template can be sent. Facebook and Instagram
495
+ # chats carry `thread_control` in `metadata` once known (`owner_app_id`,
496
+ # `expires_at`, `updated_at`).
495
497
  attr_accessor :id, :profile_id, :platform, :external_placement_id, :group,
496
498
  :participant_external_id, :participant_username,
497
499
  :participant_name, :participant_avatar_url,
@@ -1079,6 +1081,145 @@ module PostProxy
1079
1081
  attr_accessor :urn, :type, :name, :vanity_name
1080
1082
  end
1081
1083
 
1084
+ # --- Instagram collaboration invites ---
1085
+
1086
+ # `media_url` is left out by Instagram for videos with copyrighted or
1087
+ # licensed audio.
1088
+ class InstagramCollaborationInvite < Model
1089
+ attr_accessor :media_id, :media_owner_username, :caption, :media_url
1090
+
1091
+ def initialize(**attrs)
1092
+ @media_owner_username = nil
1093
+ @caption = nil
1094
+ @media_url = nil
1095
+ super
1096
+ end
1097
+ end
1098
+
1099
+ # `next_cursor` is nil on the last page.
1100
+ class InstagramCollaborationInvitesResponse < Model
1101
+ attr_accessor :collaboration_invites, :next_cursor
1102
+
1103
+ def initialize(**attrs)
1104
+ @collaboration_invites = []
1105
+ @next_cursor = nil
1106
+ super
1107
+ @collaboration_invites = (@collaboration_invites || []).map do |i|
1108
+ i.is_a?(InstagramCollaborationInvite) ? i : InstagramCollaborationInvite.new(**i.transform_keys(&:to_sym))
1109
+ end
1110
+ end
1111
+ end
1112
+
1113
+ # `accepted` is set by accept, `declined` by decline.
1114
+ class InstagramCollaborationInviteResult < Model
1115
+ attr_accessor :media_id, :accepted, :declined
1116
+ end
1117
+
1118
+ # --- Pinterest ---
1119
+
1120
+ class PinterestBoardDeleted < Model
1121
+ attr_accessor :deleted, :board_id
1122
+ end
1123
+
1124
+ # --- YouTube ---
1125
+
1126
+ class YouTubePlaylist < Model
1127
+ attr_accessor :id, :title, :description, :privacy_status, :item_count,
1128
+ :thumbnail_url, :published_at
1129
+
1130
+ def initialize(**attrs)
1131
+ @thumbnail_url = nil
1132
+ @item_count = 0
1133
+ super
1134
+ @published_at = parse_time(@published_at)
1135
+ end
1136
+
1137
+ private
1138
+
1139
+ def parse_time(value)
1140
+ return nil if value.nil?
1141
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
1142
+ end
1143
+ end
1144
+
1145
+ # `next_page_token` is nil on the last page.
1146
+ class YouTubePlaylistsResponse < Model
1147
+ attr_accessor :data, :next_page_token
1148
+
1149
+ def initialize(**attrs)
1150
+ @data = []
1151
+ @next_page_token = nil
1152
+ super
1153
+ @data = (@data || []).map do |p|
1154
+ p.is_a?(YouTubePlaylist) ? p : YouTubePlaylist.new(**p.transform_keys(&:to_sym))
1155
+ end
1156
+ end
1157
+ end
1158
+
1159
+ class YouTubePlaylistDeleted < Model
1160
+ attr_accessor :deleted, :playlist_id
1161
+ end
1162
+
1163
+ # --- Chat thread control ---
1164
+
1165
+ # Who controls a Facebook / Instagram conversation. Both nil when idle.
1166
+ class ThreadOwner < Model
1167
+ attr_accessor :app_id, :expires_at
1168
+
1169
+ def initialize(**attrs)
1170
+ @app_id = nil
1171
+ @expires_at = nil
1172
+ super
1173
+ @expires_at = parse_time(@expires_at)
1174
+ end
1175
+
1176
+ private
1177
+
1178
+ def parse_time(value)
1179
+ return nil if value.nil?
1180
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
1181
+ end
1182
+ end
1183
+
1184
+ # --- Google Business search keywords ---
1185
+
1186
+ # `impressions` is nil for low-volume keywords; `impressions_threshold`
1187
+ # is then the upper bound Google reports instead.
1188
+ class SearchKeyword < Model
1189
+ attr_accessor :keyword, :impressions, :impressions_threshold
1190
+
1191
+ def initialize(**attrs)
1192
+ @impressions = nil
1193
+ @impressions_threshold = nil
1194
+ super
1195
+ end
1196
+ end
1197
+
1198
+ # `month` is "YYYY-MM".
1199
+ class SearchKeywordsMonth < Model
1200
+ attr_accessor :month, :keywords
1201
+
1202
+ def initialize(**attrs)
1203
+ @keywords = []
1204
+ super
1205
+ @keywords = (@keywords || []).map do |k|
1206
+ k.is_a?(SearchKeyword) ? k : SearchKeyword.new(**k.transform_keys(&:to_sym))
1207
+ end
1208
+ end
1209
+ end
1210
+
1211
+ class SearchKeywordsResponse < Model
1212
+ attr_accessor :months
1213
+
1214
+ def initialize(**attrs)
1215
+ @months = []
1216
+ super
1217
+ @months = (@months || []).map do |m|
1218
+ m.is_a?(SearchKeywordsMonth) ? m : SearchKeywordsMonth.new(**m.transform_keys(&:to_sym))
1219
+ end
1220
+ end
1221
+ end
1222
+
1082
1223
  # Platform-specific parameter structs
1083
1224
 
1084
1225
  # `alt_text` on every params class that has it takes a single string or an
@@ -1108,11 +1249,17 @@ module PostProxy
1108
1249
  # `audio_volume` / `video_volume` (1-100) need a profile connected via
1109
1250
  # Facebook login. `strip_audio` removes the video's own track (reel, story).
1110
1251
  # `trial_strategy` is "MANUAL" or "SS_PERFORMANCE".
1252
+ #
1253
+ # `disable_comment` turns comments off right after publishing and
1254
+ # `location_id` (the numeric id of a Facebook Page with an address) tags a
1255
+ # location — both on posts and reels, not stories. `share_to_feed` is
1256
+ # reels-only: `false` publishes to the Reels tab only, not the feed/grid.
1111
1257
  class InstagramParams < Model
1112
1258
  attr_accessor :format, :first_comment, :collaborators, :cover_url,
1113
1259
  :cover_file, :audio_name, :audio_id, :audio_volume,
1114
1260
  :video_volume, :strip_audio, :trial_strategy, :thumb_offset,
1115
- :user_tags, :alt_text, :made_with_ai
1261
+ :user_tags, :alt_text, :made_with_ai, :disable_comment,
1262
+ :location_id, :share_to_feed
1116
1263
 
1117
1264
  def initialize(**attrs)
1118
1265
  @user_tags = nil
@@ -1151,14 +1298,21 @@ module PostProxy
1151
1298
  # for organization pages only (up to 25 codes). For format "poll",
1152
1299
  # `poll_question` (140 chars max) and `poll_options` (2-4, 30 chars max
1153
1300
  # each) are required; `poll_duration_days` is 1, 3, 7 (default) or 14.
1301
+ # `document_title` is shown above an attached document (defaults to the
1302
+ # file name). `first_comment` (1,250 characters or fewer) is posted right
1303
+ # after publishing, as the same author; not on polls, and profiles
1304
+ # connected before 2026-10-28 must reconnect.
1154
1305
  class LinkedInParams < Model
1155
1306
  attr_accessor :format, :organization_id, :reshare_urn, :alt_text, :countries,
1156
- :poll_question, :poll_options, :poll_duration_days
1307
+ :poll_question, :poll_options, :poll_duration_days,
1308
+ :document_title, :first_comment
1157
1309
  end
1158
1310
 
1311
+ # `playlist_id` adds the video to a playlist from Profiles#youtube_playlists.
1159
1312
  class YouTubeParams < Model
1160
1313
  attr_accessor :format, :title, :privacy_status, :cover_url, :cover_file,
1161
- :made_for_kids, :tags, :category_id, :contains_synthetic_media
1314
+ :made_for_kids, :tags, :category_id, :contains_synthetic_media,
1315
+ :playlist_id
1162
1316
  end
1163
1317
 
1164
1318
  class PinterestParams < Model
@@ -1,3 +1,3 @@
1
1
  module PostProxy
2
- VERSION = "1.14.0"
2
+ VERSION = "1.15.0"
3
3
  end
@@ -53,8 +53,18 @@ module PostProxy
53
53
 
54
54
  class ProfileEventData < Model
55
55
  # `origin` is set on `profile.disconnected`: "user" or "automated".
56
+ # With "automated", `error` says why PostProxy deactivated it (e.g.
57
+ # "Twitter access revoked") and `error_details` carries the raw platform
58
+ # error, when available.
56
59
  attr_accessor :id, :name, :platform, :profile_group_id, :status, :uid,
57
- :platform_id, :username, :origin
60
+ :platform_id, :username, :origin, :error, :error_details
61
+
62
+ def initialize(**attrs)
63
+ @error = nil
64
+ @error_details = nil
65
+ super
66
+ @error_details = ErrorDetails.new(**@error_details.transform_keys(&:to_sym)) if @error_details.is_a?(Hash)
67
+ end
58
68
  end
59
69
 
60
70
  # `id` is the placement's id on the platform (page id, phone number id,
@@ -157,6 +167,36 @@ module PostProxy
157
167
  end
158
168
  end
159
169
 
170
+ # Another Meta app passed or took a Facebook / Instagram conversation.
171
+ # `previous_owner_app_id` is nil when the conversation was idle.
172
+ class ThreadControlChangedEventData < Model
173
+ attr_accessor :chat, :previous_owner_app_id, :new_owner_app_id, :metadata, :occurred_at
174
+
175
+ def initialize(**attrs)
176
+ @chat = nil
177
+ @previous_owner_app_id = nil
178
+ @new_owner_app_id = nil
179
+ @metadata = nil
180
+ @occurred_at = nil
181
+ super
182
+ @chat = WebhookChatData.new(**@chat.transform_keys(&:to_sym)) if @chat.is_a?(Hash)
183
+ end
184
+ end
185
+
186
+ # Another Meta app asked PostProxy to pass a conversation.
187
+ class ThreadControlRequestedEventData < Model
188
+ attr_accessor :chat, :requested_owner_app_id, :metadata, :occurred_at
189
+
190
+ def initialize(**attrs)
191
+ @chat = nil
192
+ @requested_owner_app_id = nil
193
+ @metadata = nil
194
+ @occurred_at = nil
195
+ super
196
+ @chat = WebhookChatData.new(**@chat.transform_keys(&:to_sym)) if @chat.is_a?(Hash)
197
+ end
198
+ end
199
+
160
200
  class MessageTemplateEventData < Model
161
201
  attr_accessor :id, :external_id, :profile_id, :platform, :name, :language,
162
202
  :category, :status, :rejected_reason, :quality_score
@@ -192,6 +232,7 @@ module PostProxy
192
232
  "profile_comment.created" => ProfileCommentCreatedData,
193
233
  "profile_comment.failed" => ProfileCommentCreatedData,
194
234
  "profile_comment.failed_waiting_for_retry" => ProfileCommentCreatedData,
235
+ "profile_comment.edited" => ProfileCommentCreatedData,
195
236
  "profile_comment.deleted" => ProfileCommentCreatedData,
196
237
  "message.received" => MessageEventData,
197
238
  "message.sent" => MessageEventData,
@@ -203,6 +244,8 @@ module PostProxy
203
244
  "message.failed" => MessageEventData,
204
245
  "reaction.received" => ReactionEventData,
205
246
  "referral.received" => ReferralEventData,
247
+ "chat.thread_control_changed" => ThreadControlChangedEventData,
248
+ "chat.thread_control_requested" => ThreadControlRequestedEventData,
206
249
  "message_template.status_updated" => MessageTemplateEventData,
207
250
  "message_template.category_updated" => MessageTemplateEventData,
208
251
  "placement.name_status_updated" => PlacementEventData,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: postproxy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.14.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Postproxy