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 +4 -4
- data/README.md +60 -4
- data/lib/postproxy/constants.rb +5 -0
- data/lib/postproxy/resources/chats.rb +46 -0
- data/lib/postproxy/resources/google_business.rb +12 -0
- data/lib/postproxy/resources/messages.rb +3 -0
- data/lib/postproxy/resources/profiles.rb +113 -0
- data/lib/postproxy/types.rb +158 -4
- data/lib/postproxy/version.rb +1 -1
- data/lib/postproxy/webhook_events.rb +44 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 71cc2ca73303c214ca35568812a214fd9beffe5db6d32cf9f41d59300266262d
|
|
4
|
+
data.tar.gz: c5536be0f5e7dc3a600959ea0168ed13f875cadd5563307f12edc053c70f0147
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/postproxy/constants.rb
CHANGED
|
@@ -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)
|
data/lib/postproxy/types.rb
CHANGED
|
@@ -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
|
data/lib/postproxy/version.rb
CHANGED
|
@@ -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,
|