postproxy-sdk 1.11.0 → 1.12.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: c459486395f3eed2f4199f1946ed7a71c7b6b5de5ae270e46b088e779c772459
4
- data.tar.gz: 38788f6fd45363f0c229af3cd7fd28a79a21511d1a7b8c4c417bb2d0c5c93f48
3
+ metadata.gz: 9fabdc6a06ca3cef409cf88533c925ff183e3e1c7089fd53aa541c09bff0aa6f
4
+ data.tar.gz: 7b4245a53e37aab93f006d408430676dbe3352a8815e5479de765946d941e20e
5
5
  SHA512:
6
- metadata.gz: 8458cc1ca2c4bd873fe980d9b18db5d81b2871c3d027f914a0555ce686cbca1daba5400464f2a950ceb2ba3602657d6e2bf1daa5d5d23c891addbdc0c5b2c54b
7
- data.tar.gz: 3b9fa99981232895852351741ebdaf2cf5675d585b298100bb29ae1fdca054f67b6e16d9e909ba1db6a15f2fda1b0b06a69f37f6443eceb2680af253e0ca27a3
6
+ metadata.gz: 97e32211fa2f54a2a28b506333108a484153083efcf9272bcf23fc821e27e6e2e88be7dd7d166acf6f9cd339a69a07afd1fb9b9f3c271a860b8ec9f5b41b7a9e
7
+ data.tar.gz: 3217e3f75dd540a6c13215dc5a5b7830d483ebd38f4cd30433162e7c732f69b214b06e3f73ccfbe5683f953a9c57133bf9dd1de4bdaf4ffe5dc3a631c15a132e
data/README.md CHANGED
@@ -50,6 +50,36 @@ end
50
50
  client = PostProxy::Client.new("your-api-key", faraday_client: faraday)
51
51
  ```
52
52
 
53
+ ### Idempotency
54
+
55
+ Every write method (`POST`/`PUT`/`PATCH`/`DELETE`) accepts an `idempotency_key:`, sent as
56
+ the `Idempotency-Key` header. If the connection drops before you see the response, retry
57
+ with the same key and you get the original response back instead of a second post:
58
+
59
+ ```ruby
60
+ require "securerandom"
61
+
62
+ key = SecureRandom.uuid
63
+ post = client.posts.create("Hello", profiles: ["profile-id"], idempotency_key: key)
64
+
65
+ # Retrying the same call with the same key replays the original response.
66
+ ```
67
+
68
+ Generate a fresh key per logical operation — a UUID is ideal. Keys are scoped to your
69
+ account and may be up to 255 characters. The SDK never generates keys or retries for you.
70
+
71
+ | Situation | Result |
72
+ |---|---|
73
+ | First request with the key | Runs normally |
74
+ | Retry after a success | Original status and body replayed |
75
+ | Retry while the first is still running | `ConflictError` (409) — wait and retry |
76
+ | Same key, different request body | `ValidationError` (422) |
77
+ | Retry after an error response | Runs normally — errors are not replayed |
78
+
79
+ Only successful (`2xx`) responses are stored, so a request that failed validation or hit a
80
+ quota leaves the key free — fix the payload and retry with the same key. Stored responses
81
+ are kept for **24 hours**. Requests without a key are unaffected.
82
+
53
83
  ## Posts
54
84
 
55
85
  ```ruby
@@ -306,6 +336,15 @@ end
306
336
  # List with pagination
307
337
  comments = client.comments.list("post-id", profile_id: "profile-id", page: 2, per_page: 10)
308
338
 
339
+ # Filter by when PostProxy received the comment (created_at, not posted_at).
340
+ # A bare date means that date's start of day. Applies to top-level comments —
341
+ # one in range brings its full replies array with it.
342
+ recent = client.comments.list("post-id",
343
+ profile_id: "profile-id",
344
+ from: "2026-03-25",
345
+ to: "2026-03-26T12:00:00Z"
346
+ )
347
+
309
348
  # Get a single comment
310
349
  comment = client.comments.get("post-id", "comment-id", profile_id: "profile-id")
311
350
 
@@ -337,6 +376,38 @@ message = client.comments.private_reply("post-id", "comment-id", profile_id: "pr
337
376
  puts message.chat_id, message.status
338
377
  ```
339
378
 
379
+ ### Comments across posts
380
+
381
+ `comments.list_all` returns comments spanning every post in the profile group in one
382
+ request — the comments counterpart to `posts.stats`. Every filter is optional.
383
+
384
+ **This list is flat.** Unlike the per-post list, replies are not nested: every comment,
385
+ top-level or reply, is its own entry linked to its parent by `parent_external_id`, so
386
+ `total` counts every comment and paging is exact.
387
+
388
+ ```ruby
389
+ all = client.comments.list_all(
390
+ profiles: ["instagram", "prof-abc"], # profile IDs or network names, mixed
391
+ post_ids: ["post-1", "post-2"], # omit for every post in scope
392
+ from: "2026-03-25",
393
+ per_page: 50 # max 100
394
+ )
395
+
396
+ all.data.each do |c|
397
+ # Each entry says where it came from, so you can act on it with the
398
+ # post-scoped methods above.
399
+ puts "#{c.platform} #{c.post_id} #{c.profile_id}: #{c.body}"
400
+ puts " ↳ reply to #{c.parent_external_id}" if c.parent_external_id
401
+ end
402
+
403
+ # Reply to one of them
404
+ first = all.data.first
405
+ client.comments.create(first.post_id, "Thanks!", profile_id: first.profile_id, parent_id: first.id)
406
+ ```
407
+
408
+ Unknown or out-of-scope IDs in `post_ids` and `profiles` are ignored rather than erroring.
409
+ Results are ordered newest first by receipt time.
410
+
340
411
  ## Direct Messages
341
412
 
342
413
  Read and send 1:1 messages on DM-capable profiles (Facebook Messenger, Instagram, Telegram, Bluesky). A conversation is a **Chat**; it holds **Messages**. Outbound sends are processed asynchronously (`status` starts as `pending`).
@@ -466,6 +537,81 @@ bsky = client.profiles.get_profile_stats("prof_bsky_001")
466
537
  puts bsky.data.records.last.stats[:followersCount]
467
538
  ```
468
539
 
540
+ Every stats record (post stats and profile stats alike) carries `raw_stats` alongside the
541
+ normalized `stats`, exposing each metric under its **original platform name**:
542
+
543
+ ```ruby
544
+ stats = client.posts.stats(["post-id"])
545
+ record = stats.data["post-id"].platforms.first.records.first
546
+
547
+ puts record.stats[:impressions] # normalized
548
+ puts record.raw_stats[:views] # Instagram's own name
549
+ puts record.raw_stats[:impression_count] # Twitter/X's own name
550
+ ```
551
+
552
+ LinkedIn post stats now normalize `likes`, `comments`, `shares`, and `clicks` alongside
553
+ `impressions` — previously only `impressions` was normalized.
554
+
555
+ ### Post syncs & backfill
556
+
557
+ PostProxy mirrors posts published natively on a platform into your account. Every one of
558
+ those pulls is recorded as a **post sync**: the one fired when the profile connects, the
559
+ recurring poll, and any backfill you start.
560
+
561
+ ```ruby
562
+ # Start a backfill — walks the feed backwards from the newest post in batches
563
+ # of 25 until it reaches `from` or the platform stops returning posts.
564
+ sync = client.profiles.backfill_posts("prof-id", from: "2025-01-01")
565
+ puts sync.id, sync.status # => "sync456def" "pending"
566
+
567
+ # Poll it to completion — finished when status is "completed" or "failed"
568
+ run = client.profiles.post_sync("prof-id", sync.id)
569
+ puts "#{run.posts_imported} of #{run.posts_seen}, back to #{run.oldest_posted_at}"
570
+
571
+ # List recent runs (kept for 30 days), newest first
572
+ runs = client.profiles.post_syncs("prof-id",
573
+ trigger: "backfill", # connect | scheduled | backfill
574
+ status: "completed", # pending | running | completed | failed
575
+ per_page: 25
576
+ )
577
+ ```
578
+
579
+ | `PostSync` field | Description |
580
+ |---|---|
581
+ | `id` | Sync identifier |
582
+ | `profile_id` | Profile this run belongs to |
583
+ | `kind` | Always `posts` today |
584
+ | `trigger` | `connect`, `scheduled`, or `backfill` |
585
+ | `status` | `pending`, `running`, `completed`, or `failed` |
586
+ | `started_at` / `completed_at` | `Time` or `nil` |
587
+ | `posts_seen` | Posts the platform returned across the run |
588
+ | `posts_imported` | Posts that were **new** and got created |
589
+ | `backfill_from` | The date floor requested; `nil` for `connect`/`scheduled` |
590
+ | `oldest_posted_at` | Publish date of the oldest post the run reached |
591
+ | `error` | Platform error message when `status` is `"failed"` |
592
+ | `created_at` | `Time` |
593
+
594
+ **How far back a backfill reaches depends on the platform's API**, not on PostProxy: where
595
+ history is pageable we follow it, otherwise the run ends early with whatever it got and
596
+ still reports `status == "completed"`.
597
+
598
+ Only one backfill runs per profile at a time — starting a second raises `ConflictError`
599
+ carrying the running one's id:
600
+
601
+ ```ruby
602
+ begin
603
+ client.profiles.backfill_posts("prof-id", from: "2025-01-01")
604
+ rescue PostProxy::ConflictError => e
605
+ running_id = e.response[:profile_sync_id]
606
+ # Poll the run that's already going.
607
+ end
608
+ ```
609
+
610
+ Posts you already have are skipped, so overlapping backfills are safe. Imported posts
611
+ behave exactly like ones the poll picks up (`source: "imported"`, `post.imported`
612
+ webhook), but a backfill's follow-up work is queued at a lower priority so a deep run
613
+ can't slow down publishing.
614
+
469
615
  ## Profile Groups
470
616
 
471
617
  ```ruby
@@ -560,6 +706,43 @@ post = client.posts.create(
560
706
 
561
707
  Supported platforms: `facebook`, `instagram`, `tiktok`, `linkedin`, `youtube`, `twitter`, `threads`, `pinterest`, `bluesky`, `telegram`, `google_business`. Telegram requires a `chat_id` per post — list channels with `client.profiles.placements(profile_id)`.
562
708
 
709
+ ### Instagram user tags
710
+
711
+ Tag public Instagram accounts in a post — feed post, reel, or story:
712
+
713
+ ```ruby
714
+ client.posts.create(
715
+ "Shot on location",
716
+ profiles: ["ig-profile-id"],
717
+ media: [
718
+ "https://example.com/1.jpg",
719
+ "https://example.com/2.jpg",
720
+ "https://example.com/3.mp4"
721
+ ],
722
+ platforms: PostProxy::PlatformParams.new(
723
+ instagram: PostProxy::InstagramParams.new(
724
+ format: "post",
725
+ user_tags: [
726
+ { username: "natgeo", x: 0.5, y: 0.4 }, # slide 0
727
+ { username: "nasa", x: 0.2, y: 0.8, media_index: 1 }, # slide 1
728
+ { username: "spacex", media_index: 2 } # video — username only
729
+ ]
730
+ )
731
+ )
732
+ )
733
+ ```
734
+
735
+ - **Images require `x` and `y`** — floats `0.0`–`1.0` measured from the top-left corner.
736
+ - **Reels and video slides** are tagged by username only; coordinates are ignored and dropped.
737
+ - **Stories** accept coordinates but don't need them.
738
+ - `media_index` picks the carousel slide (0-based, defaults to `0`, video slides included).
739
+ - A leading `@` on a username is stripped for you.
740
+
741
+ Coordinates outside `0.0`–`1.0`, a `media_index` past the last media item, or an image tag
742
+ missing `x`/`y` are rejected with a `ValidationError` naming the offending entry. Accounts
743
+ that are private or have tagging turned off are silently skipped by Instagram at publish
744
+ time.
745
+
563
746
  ### Google Business
564
747
 
565
748
  Google Business posts use a `google_business` entry in `PlatformParams` (a plain hash; no typed struct). The `location_id` is the location resource path returned by `client.profiles.placements()`. Supported formats: `standard`, `event`, `offer`. CTA actions: `LEARN_MORE`, `BOOK`, `ORDER`, `SHOP`, `SIGN_UP`, `CALL`. Media is limited to one image (≤5 MB).
@@ -589,6 +772,10 @@ rescue PostProxy::AuthenticationError => e
589
772
  puts "Auth failed: #{e.message}" # 401
590
773
  rescue PostProxy::NotFoundError => e
591
774
  puts "Not found: #{e.message}" # 404
775
+ rescue PostProxy::ConflictError => e
776
+ puts "Conflict: #{e.message}" # 409
777
+ puts e.response[:duplicate_post_id] # on a duplicate post
778
+ puts e.response[:profile_sync_id] # on a backfill already running
592
779
  rescue PostProxy::ValidationError => e
593
780
  puts "Invalid: #{e.message}" # 422
594
781
  rescue PostProxy::BadRequestError => e
@@ -599,6 +786,15 @@ rescue PostProxy::Error => e
599
786
  end
600
787
  ```
601
788
 
789
+ | Status | Error | Raised for |
790
+ |---|---|---|
791
+ | 400 | `BadRequestError` | Missing required parameters |
792
+ | 401 | `AuthenticationError` | Invalid, missing, or insufficient API key permissions |
793
+ | 404 | `NotFoundError` | Resource does not exist or is not accessible |
794
+ | 409 | `ConflictError` | Duplicate submission, a backfill already running, or an in-flight `Idempotency-Key` |
795
+ | 422 | `ValidationError` | Validation failed |
796
+ | 429 | `Error` | Posting rate limit reached |
797
+
602
798
  ## License
603
799
 
604
800
  MIT
@@ -67,7 +67,8 @@ module PostProxy
67
67
  @messages ||= Resources::Messages.new(self)
68
68
  end
69
69
 
70
- def request(method, path, params: nil, json: nil, data: nil, files: nil, profile_group_id: nil)
70
+ def request(method, path, params: nil, json: nil, data: nil, files: nil, profile_group_id: nil,
71
+ idempotency_key: nil)
71
72
  url = "/api#{path}"
72
73
 
73
74
  query = {}
@@ -102,12 +103,14 @@ module PostProxy
102
103
  end
103
104
  conn.send(method, url) do |req|
104
105
  req.params = query unless query.empty?
106
+ req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
105
107
  req.body = payload
106
108
  end
107
109
  else
108
110
  conn = json_connection
109
111
  conn.send(method, url) do |req|
110
112
  req.params = query unless query.empty?
113
+ req.headers["Idempotency-Key"] = idempotency_key if idempotency_key
111
114
  req.body = json.to_json if json
112
115
  end
113
116
  end
@@ -160,6 +163,13 @@ module PostProxy
160
163
  status_code: response.status,
161
164
  response: body
162
165
  )
166
+ when 409
167
+ body = parse_error_body(response)
168
+ raise ConflictError.new(
169
+ error_message(body),
170
+ status_code: response.status,
171
+ response: body
172
+ )
163
173
  when 422
164
174
  body = parse_error_body(response)
165
175
  raise ValidationError.new(
@@ -11,6 +11,12 @@ module PostProxy
11
11
 
12
12
  class AuthenticationError < Error; end
13
13
  class NotFoundError < Error; end
14
+
15
+ # 409. Raised for a duplicate submission (`response[:duplicate_post_id]`), a
16
+ # backfill that is already running (`response[:profile_sync_id]`), or a
17
+ # request whose Idempotency-Key is still in flight.
18
+ class ConflictError < Error; end
19
+
14
20
  class ValidationError < Error; end
15
21
  class BadRequestError < Error; end
16
22
  end
@@ -22,12 +22,17 @@ module PostProxy
22
22
  )
23
23
  end
24
24
 
25
- def create(profile_id, participant_external_id, participant_username: nil, participant_name: nil, profile_group_id: nil)
25
+ def create(profile_id, participant_external_id, participant_username: nil, participant_name: nil,
26
+ profile_group_id: nil, idempotency_key: nil)
26
27
  json_body = { participant_external_id: participant_external_id }
27
28
  json_body[:participant_username] = participant_username if participant_username
28
29
  json_body[:participant_name] = participant_name if participant_name
29
30
 
30
- result = @client.request(:post, "/profiles/#{profile_id}/chats", json: json_body, profile_group_id: profile_group_id)
31
+ result = @client.request(:post, "/profiles/#{profile_id}/chats",
32
+ json: json_body,
33
+ profile_group_id: profile_group_id,
34
+ idempotency_key: idempotency_key
35
+ )
31
36
  Chat.new(**result)
32
37
  end
33
38
 
@@ -36,13 +41,19 @@ module PostProxy
36
41
  Chat.new(**result)
37
42
  end
38
43
 
39
- def archive(chat_id, profile_group_id: nil)
40
- result = @client.request(:post, "/chats/#{chat_id}/archive", profile_group_id: profile_group_id)
44
+ def archive(chat_id, profile_group_id: nil, idempotency_key: nil)
45
+ result = @client.request(:post, "/chats/#{chat_id}/archive",
46
+ profile_group_id: profile_group_id,
47
+ idempotency_key: idempotency_key
48
+ )
41
49
  Chat.new(**result)
42
50
  end
43
51
 
44
- def unarchive(chat_id, profile_group_id: nil)
45
- result = @client.request(:delete, "/chats/#{chat_id}/archive", profile_group_id: profile_group_id)
52
+ def unarchive(chat_id, profile_group_id: nil, idempotency_key: nil)
53
+ result = @client.request(:delete, "/chats/#{chat_id}/archive",
54
+ profile_group_id: profile_group_id,
55
+ idempotency_key: idempotency_key
56
+ )
46
57
  Chat.new(**result)
47
58
  end
48
59
 
@@ -5,10 +5,15 @@ module PostProxy
5
5
  @client = client
6
6
  end
7
7
 
8
- def list(post_id, profile_id:, page: nil, per_page: nil)
8
+ # `from` and `to` filter on when PostProxy received the comment
9
+ # (`created_at`), not the platform's `posted_at`. They apply to top-level
10
+ # comments — one in range brings its full `replies` array with it.
11
+ def list(post_id, profile_id:, page: nil, per_page: nil, from: nil, to: nil)
9
12
  params = { profile_id: profile_id }
10
13
  params[:page] = page if page
11
14
  params[:per_page] = per_page if per_page
15
+ params[:from] = from if from
16
+ params[:to] = to if to
12
17
 
13
18
  result = @client.request(:get, "/posts/#{post_id}/comments", params: params)
14
19
  comments = (result[:data] || []).map { |c| Comment.new(**c) }
@@ -20,48 +25,95 @@ module PostProxy
20
25
  )
21
26
  end
22
27
 
28
+ # Comments across every post in the profile group. Flat: replies come
29
+ # back as their own entries linked by `parent_external_id`, so `total`
30
+ # counts every comment. `profiles` takes profile IDs or network names,
31
+ # mixed.
32
+ def list_all(post_ids: nil, profiles: nil, from: nil, to: nil, page: nil, per_page: nil,
33
+ profile_group_id: nil)
34
+ params = {}
35
+ params[:post_ids] = Array(post_ids).join(",") if post_ids
36
+ params[:profiles] = Array(profiles).join(",") if profiles
37
+ params[:from] = from if from
38
+ params[:to] = to if to
39
+ params[:page] = page if page
40
+ params[:per_page] = per_page if per_page
41
+
42
+ result = @client.request(:get, "/comments",
43
+ params: params.empty? ? nil : params,
44
+ profile_group_id: profile_group_id
45
+ )
46
+ comments = (result[:data] || []).map { |c| BulkComment.new(**c) }
47
+ PaginatedResponse.new(
48
+ data: comments,
49
+ total: result[:total],
50
+ page: result[:page],
51
+ per_page: result[:per_page]
52
+ )
53
+ end
54
+
23
55
  def get(post_id, comment_id, profile_id:)
24
56
  result = @client.request(:get, "/posts/#{post_id}/comments/#{comment_id}", params: { profile_id: profile_id })
25
57
  Comment.new(**result)
26
58
  end
27
59
 
28
- def create(post_id, text, profile_id:, parent_id: nil)
60
+ def create(post_id, text, profile_id:, parent_id: nil, idempotency_key: nil)
29
61
  json_body = { text: text }
30
62
  json_body[:parent_id] = parent_id if parent_id
31
63
 
32
- result = @client.request(:post, "/posts/#{post_id}/comments", params: { profile_id: profile_id }, json: json_body)
64
+ result = @client.request(:post, "/posts/#{post_id}/comments",
65
+ params: { profile_id: profile_id },
66
+ json: json_body,
67
+ idempotency_key: idempotency_key
68
+ )
33
69
  Comment.new(**result)
34
70
  end
35
71
 
36
- def delete(post_id, comment_id, profile_id:)
37
- result = @client.request(:delete, "/posts/#{post_id}/comments/#{comment_id}", params: { profile_id: profile_id })
72
+ def delete(post_id, comment_id, profile_id:, idempotency_key: nil)
73
+ result = @client.request(:delete, "/posts/#{post_id}/comments/#{comment_id}",
74
+ params: { profile_id: profile_id },
75
+ idempotency_key: idempotency_key
76
+ )
38
77
  AcceptedResponse.new(**result)
39
78
  end
40
79
 
41
- def hide(post_id, comment_id, profile_id:)
42
- result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/hide", params: { profile_id: profile_id })
80
+ def hide(post_id, comment_id, profile_id:, idempotency_key: nil)
81
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/hide",
82
+ params: { profile_id: profile_id },
83
+ idempotency_key: idempotency_key
84
+ )
43
85
  AcceptedResponse.new(**result)
44
86
  end
45
87
 
46
- def unhide(post_id, comment_id, profile_id:)
47
- result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/unhide", params: { profile_id: profile_id })
88
+ def unhide(post_id, comment_id, profile_id:, idempotency_key: nil)
89
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/unhide",
90
+ params: { profile_id: profile_id },
91
+ idempotency_key: idempotency_key
92
+ )
48
93
  AcceptedResponse.new(**result)
49
94
  end
50
95
 
51
- def like(post_id, comment_id, profile_id:)
52
- result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/like", params: { profile_id: profile_id })
96
+ def like(post_id, comment_id, profile_id:, idempotency_key: nil)
97
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/like",
98
+ params: { profile_id: profile_id },
99
+ idempotency_key: idempotency_key
100
+ )
53
101
  AcceptedResponse.new(**result)
54
102
  end
55
103
 
56
- def unlike(post_id, comment_id, profile_id:)
57
- result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/unlike", params: { profile_id: profile_id })
104
+ def unlike(post_id, comment_id, profile_id:, idempotency_key: nil)
105
+ result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/unlike",
106
+ params: { profile_id: profile_id },
107
+ idempotency_key: idempotency_key
108
+ )
58
109
  AcceptedResponse.new(**result)
59
110
  end
60
111
 
61
- def private_reply(post_id, comment_id, profile_id:, text:)
112
+ def private_reply(post_id, comment_id, profile_id:, text:, idempotency_key: nil)
62
113
  result = @client.request(:post, "/posts/#{post_id}/comments/#{comment_id}/private_reply",
63
114
  params: { profile_id: profile_id },
64
- json: { text: text }
115
+ json: { text: text },
116
+ idempotency_key: idempotency_key
65
117
  )
66
118
  Message.new(**result)
67
119
  end
@@ -23,7 +23,8 @@ module PostProxy
23
23
  end
24
24
 
25
25
  def send_message(chat_id, body: nil, media: nil, media_files: nil, tag: nil,
26
- reply_to_external_id: nil, reply_markup: nil, profile_group_id: nil)
26
+ reply_to_external_id: nil, reply_markup: nil, profile_group_id: nil,
27
+ idempotency_key: nil)
27
28
  has_files = media_files && !media_files.empty?
28
29
 
29
30
  if has_files
@@ -47,7 +48,8 @@ module PostProxy
47
48
  result = @client.request(:post, "/chats/#{chat_id}/messages",
48
49
  data: form_data,
49
50
  files: files,
50
- profile_group_id: profile_group_id
51
+ profile_group_id: profile_group_id,
52
+ idempotency_key: idempotency_key
51
53
  )
52
54
  else
53
55
  json_body = {}
@@ -57,7 +59,11 @@ module PostProxy
57
59
  json_body[:reply_to_external_id] = reply_to_external_id if reply_to_external_id
58
60
  json_body[:reply_markup] = reply_markup if reply_markup
59
61
 
60
- result = @client.request(:post, "/chats/#{chat_id}/messages", json: json_body, profile_group_id: profile_group_id)
62
+ result = @client.request(:post, "/chats/#{chat_id}/messages",
63
+ json: json_body,
64
+ profile_group_id: profile_group_id,
65
+ idempotency_key: idempotency_key
66
+ )
61
67
  end
62
68
 
63
69
  Message.new(**result)
@@ -69,29 +75,37 @@ module PostProxy
69
75
  Message.new(**result)
70
76
  end
71
77
 
72
- def edit(message_id, body: nil, reply_markup: nil, profile_group_id: nil)
78
+ def edit(message_id, body: nil, reply_markup: nil, profile_group_id: nil, idempotency_key: nil)
73
79
  json_body = {}
74
80
  json_body[:body] = body if body
75
81
  json_body[:reply_markup] = reply_markup if reply_markup
76
82
 
77
- result = @client.request(:patch, "/messages/#{message_id}", json: json_body, profile_group_id: profile_group_id)
83
+ result = @client.request(:patch, "/messages/#{message_id}",
84
+ json: json_body,
85
+ profile_group_id: profile_group_id,
86
+ idempotency_key: idempotency_key
87
+ )
78
88
  Message.new(**result)
79
89
  end
80
90
 
81
- def react(message_id, reaction: nil, emoji: nil, profile_group_id: nil)
91
+ def react(message_id, reaction: nil, emoji: nil, profile_group_id: nil, idempotency_key: nil)
82
92
  json_body = {}
83
93
  json_body[:reaction] = reaction if reaction
84
94
  json_body[:emoji] = emoji if emoji
85
95
 
86
96
  result = @client.request(:post, "/messages/#{message_id}/react",
87
97
  json: json_body.empty? ? nil : json_body,
88
- profile_group_id: profile_group_id
98
+ profile_group_id: profile_group_id,
99
+ idempotency_key: idempotency_key
89
100
  )
90
101
  Message.new(**result)
91
102
  end
92
103
 
93
- def unreact(message_id, profile_group_id: nil)
94
- result = @client.request(:delete, "/messages/#{message_id}/unreact", profile_group_id: profile_group_id)
104
+ def unreact(message_id, profile_group_id: nil, idempotency_key: nil)
105
+ result = @client.request(:delete, "/messages/#{message_id}/unreact",
106
+ profile_group_id: profile_group_id,
107
+ idempotency_key: idempotency_key
108
+ )
95
109
  Message.new(**result)
96
110
  end
97
111
 
@@ -30,7 +30,7 @@ module PostProxy
30
30
 
31
31
  def create(body, profiles:, media: nil, media_files: nil, platforms: nil,
32
32
  thread: nil, scheduled_at: nil, draft: nil, queue_id: nil,
33
- queue_priority: nil, profile_group_id: nil)
33
+ queue_priority: nil, profile_group_id: nil, idempotency_key: nil)
34
34
  has_files = media_files && !media_files.empty?
35
35
  has_thread_files = thread&.any? { |t| t[:media_files]&.any? }
36
36
 
@@ -87,7 +87,8 @@ module PostProxy
87
87
  result = @client.request(:post, "/posts",
88
88
  data: form_data,
89
89
  files: files,
90
- profile_group_id: profile_group_id
90
+ profile_group_id: profile_group_id,
91
+ idempotency_key: idempotency_key
91
92
  )
92
93
  else
93
94
  post_payload = { body: body }
@@ -101,7 +102,11 @@ module PostProxy
101
102
  json_body[:queue_id] = queue_id if queue_id
102
103
  json_body[:queue_priority] = queue_priority if queue_priority
103
104
 
104
- result = @client.request(:post, "/posts", json: json_body, profile_group_id: profile_group_id)
105
+ result = @client.request(:post, "/posts",
106
+ json: json_body,
107
+ profile_group_id: profile_group_id,
108
+ idempotency_key: idempotency_key
109
+ )
105
110
  end
106
111
 
107
112
  Post.new(**result)
@@ -109,7 +114,7 @@ module PostProxy
109
114
 
110
115
  def update(id, body: nil, profiles: nil, media: nil, media_files: nil, platforms: nil,
111
116
  thread: nil, scheduled_at: nil, draft: nil, queue_id: nil,
112
- queue_priority: nil, profile_group_id: nil)
117
+ queue_priority: nil, profile_group_id: nil, idempotency_key: nil)
113
118
  has_files = media_files && !media_files.empty?
114
119
  has_thread_files = thread&.any? { |t| t[:media_files]&.any? }
115
120
 
@@ -167,7 +172,8 @@ module PostProxy
167
172
  result = @client.request(:patch, "/posts/#{id}",
168
173
  data: form_data,
169
174
  files: files,
170
- profile_group_id: profile_group_id
175
+ profile_group_id: profile_group_id,
176
+ idempotency_key: idempotency_key
171
177
  )
172
178
  else
173
179
  json_body = {}
@@ -185,14 +191,21 @@ module PostProxy
185
191
  json_body[:queue_id] = queue_id if queue_id
186
192
  json_body[:queue_priority] = queue_priority if queue_priority
187
193
 
188
- result = @client.request(:patch, "/posts/#{id}", json: json_body, profile_group_id: profile_group_id)
194
+ result = @client.request(:patch, "/posts/#{id}",
195
+ json: json_body,
196
+ profile_group_id: profile_group_id,
197
+ idempotency_key: idempotency_key
198
+ )
189
199
  end
190
200
 
191
201
  Post.new(**result)
192
202
  end
193
203
 
194
- def publish_draft(id, profile_group_id: nil)
195
- result = @client.request(:post, "/posts/#{id}/publish", profile_group_id: profile_group_id)
204
+ def publish_draft(id, profile_group_id: nil, idempotency_key: nil)
205
+ result = @client.request(:post, "/posts/#{id}/publish",
206
+ profile_group_id: profile_group_id,
207
+ idempotency_key: idempotency_key
208
+ )
196
209
  Post.new(**result)
197
210
  end
198
211
 
@@ -209,24 +222,27 @@ module PostProxy
209
222
  StatsResponse.new(data: posts)
210
223
  end
211
224
 
212
- def delete(id, delete_on_platform: nil, profile_group_id: nil)
225
+ def delete(id, delete_on_platform: nil, profile_group_id: nil, idempotency_key: nil)
213
226
  params = {}
214
227
  params[:delete_on_platform] = delete_on_platform unless delete_on_platform.nil?
215
228
  result = @client.request(:delete, "/posts/#{id}",
216
229
  params: params.empty? ? nil : params,
217
- profile_group_id: profile_group_id
230
+ profile_group_id: profile_group_id,
231
+ idempotency_key: idempotency_key
218
232
  )
219
233
  DeleteResponse.new(**result)
220
234
  end
221
235
 
222
- def delete_on_platform(id, post_profile_id: nil, profile_id: nil, network: nil, profile_group_id: nil)
236
+ def delete_on_platform(id, post_profile_id: nil, profile_id: nil, network: nil, profile_group_id: nil,
237
+ idempotency_key: nil)
223
238
  json_body = {}
224
239
  json_body[:post_profile_id] = post_profile_id if post_profile_id
225
240
  json_body[:profile_id] = profile_id if profile_id
226
241
  json_body[:network] = network if network
227
242
  result = @client.request(:post, "/posts/#{id}/delete_on_platform",
228
243
  json: json_body.empty? ? nil : json_body,
229
- profile_group_id: profile_group_id
244
+ profile_group_id: profile_group_id,
245
+ idempotency_key: idempotency_key
230
246
  )
231
247
  DeleteOnPlatformResponse.new(**result)
232
248
  end
@@ -26,14 +26,16 @@ module PostProxy
26
26
  ProfileComment.new(**result)
27
27
  end
28
28
 
29
- def create(profile_id, parent_id:, text:)
29
+ def create(profile_id, parent_id:, text:, idempotency_key: nil)
30
30
  result = @client.request(:post, "/profiles/#{profile_id}/comments",
31
- json: { parent_id: parent_id, text: text })
31
+ json: { parent_id: parent_id, text: text },
32
+ idempotency_key: idempotency_key)
32
33
  ProfileComment.new(**result)
33
34
  end
34
35
 
35
- def delete(profile_id, comment_id)
36
- result = @client.request(:delete, "/profiles/#{profile_id}/comments/#{comment_id}")
36
+ def delete(profile_id, comment_id, idempotency_key: nil)
37
+ result = @client.request(:delete, "/profiles/#{profile_id}/comments/#{comment_id}",
38
+ idempotency_key: idempotency_key)
37
39
  AcceptedResponse.new(**result)
38
40
  end
39
41
  end
@@ -16,13 +16,16 @@ module PostProxy
16
16
  ProfileGroup.new(**result)
17
17
  end
18
18
 
19
- def create(name)
20
- result = @client.request(:post, "/profile_groups", json: { name: name })
19
+ def create(name, idempotency_key: nil)
20
+ result = @client.request(:post, "/profile_groups",
21
+ json: { name: name },
22
+ idempotency_key: idempotency_key
23
+ )
21
24
  ProfileGroup.new(**result)
22
25
  end
23
26
 
24
- def delete(id)
25
- result = @client.request(:delete, "/profile_groups/#{id}")
27
+ def delete(id, idempotency_key: nil)
28
+ result = @client.request(:delete, "/profile_groups/#{id}", idempotency_key: idempotency_key)
26
29
  DeleteResponse.new(**result)
27
30
  end
28
31
 
@@ -39,17 +39,64 @@ module PostProxy
39
39
  # Moves a placement (e.g. a Facebook Page or Telegram channel) to another
40
40
  # profile group. `placement_id` is the placement's external ID as
41
41
  # returned by #placements.
42
- def assign_placement_to_group(id, placement_id:, target_profile_group_id:, profile_group_id: nil)
42
+ def assign_placement_to_group(id, placement_id:, target_profile_group_id:, profile_group_id: nil,
43
+ idempotency_key: nil)
43
44
  result = @client.request(:patch, "/profiles/#{id}/assign_placement_to_group",
44
45
  json: {
45
46
  placement_id: placement_id,
46
47
  target_profile_group_id: target_profile_group_id
47
48
  },
48
- profile_group_id: profile_group_id
49
+ profile_group_id: profile_group_id,
50
+ idempotency_key: idempotency_key
49
51
  )
50
52
  Placement.new(**result)
51
53
  end
52
54
 
55
+ # Imports older posts from the platform. Walks the profile's feed
56
+ # backwards from the newest post until it reaches `from` or the platform
57
+ # stops returning posts. Runs in the background — poll #post_sync with
58
+ # the returned id for progress. Only one backfill runs per profile;
59
+ # starting a second raises ConflictError carrying the running one's
60
+ # `profile_sync_id`.
61
+ def backfill_posts(id, from:, profile_group_id: nil, idempotency_key: nil)
62
+ result = @client.request(:post, "/profiles/#{id}/backfill_posts",
63
+ json: { from: from },
64
+ profile_group_id: profile_group_id,
65
+ idempotency_key: idempotency_key
66
+ )
67
+ PostSync.new(**result)
68
+ end
69
+
70
+ # Lists post sync runs, newest first. Runs are kept for 30 days.
71
+ def post_syncs(id, trigger: nil, status: nil, page: nil, per_page: nil, profile_group_id: nil)
72
+ params = {}
73
+ params[:trigger] = trigger if trigger
74
+ params[:status] = status if status
75
+ params[:page] = page if page
76
+ params[:per_page] = per_page if per_page
77
+
78
+ result = @client.request(:get, "/profiles/#{id}/post_syncs",
79
+ params: params.empty? ? nil : params,
80
+ profile_group_id: profile_group_id
81
+ )
82
+ syncs = (result[:data] || []).map { |s| PostSync.new(**s) }
83
+ PaginatedResponse.new(
84
+ data: syncs,
85
+ total: result[:total],
86
+ page: result[:page],
87
+ per_page: result[:per_page]
88
+ )
89
+ end
90
+
91
+ # Fetches a single run. Poll this to follow a backfill to completion —
92
+ # the run is finished when `status` is "completed" or "failed".
93
+ def post_sync(id, post_sync_id, profile_group_id: nil)
94
+ result = @client.request(:get, "/profiles/#{id}/post_syncs/#{post_sync_id}",
95
+ profile_group_id: profile_group_id
96
+ )
97
+ PostSync.new(**result)
98
+ end
99
+
53
100
  # Lists DM ice breakers. Supported for Instagram profiles only.
54
101
  def ice_breakers(id, profile_group_id: nil)
55
102
  result = @client.request(:get, "/profiles/#{id}/ice_breakers", profile_group_id: profile_group_id)
@@ -57,22 +104,29 @@ module PostProxy
57
104
  end
58
105
 
59
106
  # Replaces the DM ice breakers for a profile (1-4 items).
60
- def set_ice_breakers(id, ice_breakers, profile_group_id: nil)
107
+ def set_ice_breakers(id, ice_breakers, profile_group_id: nil, idempotency_key: nil)
61
108
  items = ice_breakers.map { |ib| ib.is_a?(IceBreaker) ? ib.to_h : ib }
62
109
  result = @client.request(:post, "/profiles/#{id}/ice_breakers",
63
110
  json: { ice_breakers: items },
64
- profile_group_id: profile_group_id
111
+ profile_group_id: profile_group_id,
112
+ idempotency_key: idempotency_key
65
113
  )
66
114
  SuccessResponse.new(**result)
67
115
  end
68
116
 
69
- def delete_ice_breakers(id, profile_group_id: nil)
70
- result = @client.request(:delete, "/profiles/#{id}/ice_breakers", profile_group_id: profile_group_id)
117
+ def delete_ice_breakers(id, profile_group_id: nil, idempotency_key: nil)
118
+ result = @client.request(:delete, "/profiles/#{id}/ice_breakers",
119
+ profile_group_id: profile_group_id,
120
+ idempotency_key: idempotency_key
121
+ )
71
122
  SuccessResponse.new(**result)
72
123
  end
73
124
 
74
- def delete(id, profile_group_id: nil)
75
- result = @client.request(:delete, "/profiles/#{id}", profile_group_id: profile_group_id)
125
+ def delete(id, profile_group_id: nil, idempotency_key: nil)
126
+ result = @client.request(:delete, "/profiles/#{id}",
127
+ profile_group_id: profile_group_id,
128
+ idempotency_key: idempotency_key
129
+ )
76
130
  SuccessResponse.new(**result)
77
131
  end
78
132
  end
@@ -21,7 +21,8 @@ module PostProxy
21
21
  NextSlotResponse.new(**result)
22
22
  end
23
23
 
24
- def create(name, profile_group_id:, description: nil, timezone: nil, jitter: nil, timeslots: nil)
24
+ def create(name, profile_group_id:, description: nil, timezone: nil, jitter: nil, timeslots: nil,
25
+ idempotency_key: nil)
25
26
  post_queue = { name: name }
26
27
  post_queue[:description] = description if description
27
28
  post_queue[:timezone] = timezone if timezone
@@ -33,11 +34,12 @@ module PostProxy
33
34
  post_queue: post_queue,
34
35
  }
35
36
 
36
- result = @client.request(:post, "/post_queues", json: json_body)
37
+ result = @client.request(:post, "/post_queues", json: json_body, idempotency_key: idempotency_key)
37
38
  Queue.new(**result)
38
39
  end
39
40
 
40
- def update(id, name: nil, description: nil, timezone: nil, enabled: nil, jitter: nil, timeslots: nil)
41
+ def update(id, name: nil, description: nil, timezone: nil, enabled: nil, jitter: nil, timeslots: nil,
42
+ idempotency_key: nil)
41
43
  post_queue = {}
42
44
  post_queue[:name] = name unless name.nil?
43
45
  post_queue[:description] = description unless description.nil?
@@ -48,12 +50,12 @@ module PostProxy
48
50
 
49
51
  json_body = { post_queue: post_queue }
50
52
 
51
- result = @client.request(:patch, "/post_queues/#{id}", json: json_body)
53
+ result = @client.request(:patch, "/post_queues/#{id}", json: json_body, idempotency_key: idempotency_key)
52
54
  Queue.new(**result)
53
55
  end
54
56
 
55
- def delete(id)
56
- result = @client.request(:delete, "/post_queues/#{id}")
57
+ def delete(id, idempotency_key: nil)
58
+ result = @client.request(:delete, "/post_queues/#{id}", idempotency_key: idempotency_key)
57
59
  DeleteResponse.new(**result)
58
60
  end
59
61
  end
@@ -16,27 +16,27 @@ module PostProxy
16
16
  Webhook.new(**result)
17
17
  end
18
18
 
19
- def create(url, events:, description: nil)
19
+ def create(url, events:, description: nil, idempotency_key: nil)
20
20
  json_body = { url: url, events: events }
21
21
  json_body[:description] = description if description
22
22
 
23
- result = @client.request(:post, "/webhooks", json: json_body)
23
+ result = @client.request(:post, "/webhooks", json: json_body, idempotency_key: idempotency_key)
24
24
  Webhook.new(**result)
25
25
  end
26
26
 
27
- def update(id, url: nil, events: nil, enabled: nil, description: nil)
27
+ def update(id, url: nil, events: nil, enabled: nil, description: nil, idempotency_key: nil)
28
28
  json_body = {}
29
29
  json_body[:url] = url unless url.nil?
30
30
  json_body[:events] = events unless events.nil?
31
31
  json_body[:enabled] = enabled unless enabled.nil?
32
32
  json_body[:description] = description unless description.nil?
33
33
 
34
- result = @client.request(:patch, "/webhooks/#{id}", json: json_body)
34
+ result = @client.request(:patch, "/webhooks/#{id}", json: json_body, idempotency_key: idempotency_key)
35
35
  Webhook.new(**result)
36
36
  end
37
37
 
38
- def delete(id)
39
- result = @client.request(:delete, "/webhooks/#{id}")
38
+ def delete(id, idempotency_key: nil)
39
+ result = @client.request(:delete, "/webhooks/#{id}", idempotency_key: idempotency_key)
40
40
  DeleteResponse.new(**result)
41
41
  end
42
42
 
@@ -261,10 +261,13 @@ module PostProxy
261
261
  end
262
262
 
263
263
  class StatsRecord < Model
264
- attr_accessor :stats, :recorded_at
264
+ # `raw_stats` carries every metric under its original platform name, e.g.
265
+ # `views` for Instagram or `impression_count` for Twitter/X.
266
+ attr_accessor :stats, :raw_stats, :recorded_at
265
267
 
266
268
  def initialize(**attrs)
267
269
  @stats = {}
270
+ @raw_stats = {}
268
271
  @recorded_at = nil
269
272
  super
270
273
  @recorded_at = parse_time(@recorded_at)
@@ -505,6 +508,79 @@ module PostProxy
505
508
  attr_accessor :accepted
506
509
  end
507
510
 
511
+ # A comment from Comments#list_all. Flat: replies are their own entries
512
+ # linked to their parent by `parent_external_id` rather than nested under
513
+ # `replies`.
514
+ class BulkComment < Model
515
+ attr_accessor :post_id, :profile_id, :platform, :id, :external_id, :body,
516
+ :status, :author_username, :author_avatar_url,
517
+ :author_external_id, :metadata, :parent_external_id,
518
+ :like_count, :is_hidden, :permalink, :platform_data,
519
+ :attachments, :posted_at, :created_at
520
+
521
+ def initialize(**attrs)
522
+ @external_id = nil
523
+ @author_username = nil
524
+ @author_avatar_url = nil
525
+ @author_external_id = nil
526
+ @metadata = nil
527
+ @parent_external_id = nil
528
+ @like_count = 0
529
+ @is_hidden = false
530
+ @permalink = nil
531
+ @platform_data = nil
532
+ @attachments = []
533
+ @posted_at = nil
534
+ super
535
+ @posted_at = parse_time(@posted_at)
536
+ @created_at = parse_time(@created_at)
537
+ @attachments = (@attachments || []).map do |a|
538
+ a.is_a?(Attachment) ? a : Attachment.new(**a.transform_keys(&:to_sym))
539
+ end
540
+ end
541
+
542
+ private
543
+
544
+ def parse_time(value)
545
+ return nil if value.nil?
546
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
547
+ end
548
+ end
549
+
550
+ # A record of one post pull for a profile — the sync fired when the profile
551
+ # connects, the recurring poll, or a backfill.
552
+ class PostSync < Model
553
+ attr_accessor :id, :profile_id, :kind, :trigger, :status, :started_at,
554
+ :completed_at, :posts_seen, :posts_imported, :backfill_from,
555
+ :oldest_posted_at, :error, :created_at
556
+
557
+ def initialize(**attrs)
558
+ @started_at = nil
559
+ @completed_at = nil
560
+ @posts_seen = 0
561
+ # Posts that were new and got created — lower than `posts_seen` whenever
562
+ # the run re-read posts you already have.
563
+ @posts_imported = 0
564
+ @backfill_from = nil
565
+ # Publish date of the oldest post the run reached.
566
+ @oldest_posted_at = nil
567
+ @error = nil
568
+ super
569
+ @started_at = parse_time(@started_at)
570
+ @completed_at = parse_time(@completed_at)
571
+ @backfill_from = parse_time(@backfill_from)
572
+ @oldest_posted_at = parse_time(@oldest_posted_at)
573
+ @created_at = parse_time(@created_at)
574
+ end
575
+
576
+ private
577
+
578
+ def parse_time(value)
579
+ return nil if value.nil?
580
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
581
+ end
582
+ end
583
+
508
584
  class DeleteResponse < Model
509
585
  attr_accessor :deleted
510
586
  end
@@ -589,9 +665,37 @@ module PostProxy
589
665
  attr_accessor :format, :title, :first_comment, :page_id
590
666
  end
591
667
 
668
+ # An Instagram account to tag in a post. Images require `x` and `y`; reels
669
+ # and video slides are tagged by username only — Instagram ignores
670
+ # coordinates there. `media_index` picks the carousel slide (0-based).
671
+ class InstagramUserTag < Model
672
+ attr_accessor :username, :x, :y, :media_index
673
+ end
674
+
592
675
  class InstagramParams < Model
593
676
  attr_accessor :format, :first_comment, :collaborators, :cover_url,
594
- :audio_name, :trial_strategy, :thumb_offset
677
+ :audio_name, :trial_strategy, :thumb_offset, :user_tags
678
+
679
+ def initialize(**attrs)
680
+ @user_tags = nil
681
+ super
682
+ @user_tags = @user_tags&.map do |t|
683
+ t.is_a?(InstagramUserTag) ? t : InstagramUserTag.new(**t.transform_keys(&:to_sym))
684
+ end
685
+ end
686
+
687
+ # User tags must reach the wire as plain hashes, with unset coordinates
688
+ # dropped rather than sent as nulls.
689
+ def to_h
690
+ result = super
691
+ tags = result[:user_tags]
692
+ return result if tags.nil?
693
+
694
+ result[:user_tags] = tags.map do |t|
695
+ (t.is_a?(Model) ? t.to_h : t).reject { |_, v| v.nil? }
696
+ end
697
+ result
698
+ end
595
699
  end
596
700
 
597
701
  class TikTokParams < Model
@@ -1,3 +1,3 @@
1
1
  module PostProxy
2
- VERSION = "1.11.0"
2
+ VERSION = "1.12.0"
3
3
  end
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.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - PostProxy