postproxy-sdk 1.11.0 → 1.13.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: a6be6a31464b3afaf473473d94fd639b2553782cf0494e87ccfebb3e5e2e5e4a
4
+ data.tar.gz: 0ff052e8410c536e2938cf6887cecee713f615afa11207c88f733c29b6770d07
5
5
  SHA512:
6
- metadata.gz: 8458cc1ca2c4bd873fe980d9b18db5d81b2871c3d027f914a0555ce686cbca1daba5400464f2a950ceb2ba3602657d6e2bf1daa5d5d23c891addbdc0c5b2c54b
7
- data.tar.gz: 3b9fa99981232895852351741ebdaf2cf5675d585b298100bb29ae1fdca054f67b6e16d9e909ba1db6a15f2fda1b0b06a69f37f6443eceb2680af253e0ca27a3
6
+ metadata.gz: 2a67b96326d47a4c33463c10b5346ff28debf6e4faf7ef11a297fded79abf9cac4b062099cc1e1cf6002e6e9f2bc4b9e641dbf26537552f8d2041310598a8163
7
+ data.tar.gz: cf8c37298d421c37886440e75819a9a1462d34ccadad79e4623cbb2cb431292e4ead2e1ba0014bd13b522c9c0e62737eeb524987bde5a97f99404e3416686950
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`).
@@ -393,6 +464,73 @@ message = client.comments.private_reply("post-id", "comment-id", profile_id: "pr
393
464
  puts message.chat_id, message.status
394
465
  ```
395
466
 
467
+ ### Quick replies and buttons (Facebook & Instagram)
468
+
469
+ Meta's two interactive primitives. **Quick replies** are chips above the participant's
470
+ composer that disappear once tapped; **buttons** are attached to the message and stay in
471
+ the thread. Telegram's equivalent is `reply_markup` above — passing `quick_replies` or
472
+ `buttons` on a Telegram or Bluesky chat returns `422`.
473
+
474
+ Each param accepts model instances or plain hashes, whichever you prefer:
475
+
476
+ ```ruby
477
+ # Quick replies — up to 13. title ≤ 20 chars, payload ≤ 1000.
478
+ client.messages.send(
479
+ chat.id,
480
+ body: "What can I help with?",
481
+ quick_replies: [
482
+ PostProxy::QuickReply.new(title: "Track order", payload: "TRACK"),
483
+ { title: "Talk to support", payload: "HELP" }
484
+ ]
485
+ )
486
+
487
+ # Buttons — up to 3, each either web_url or postback. card is optional and
488
+ # requires buttons.
489
+ client.messages.send(
490
+ chat.id,
491
+ body: "Your order shipped",
492
+ buttons: [
493
+ PostProxy::MessageButton.new(type: "web_url", title: "Track", url: "https://shop.example.com/o/123"),
494
+ PostProxy::MessageButton.new(type: "postback", title: "Cancel", payload: "CANCEL:123")
495
+ ],
496
+ card: PostProxy::MessageCard.new(
497
+ subtitle: "Arriving Friday",
498
+ image_url: "https://cdn.example.com/shoe.png",
499
+ default_action: { type: "web_url", url: "https://shop.example.com/o/123" }
500
+ )
501
+ )
502
+ ```
503
+
504
+ Buttons are delivered as a Meta generic template and your `body` becomes the template's
505
+ element title — so **`body` is capped at 80 characters when buttons are present**. That is
506
+ Meta's limit, not PostProxy's, and a longer body is rejected with a `422` naming the
507
+ length. Buttons cannot be combined with media. Instagram is stricter than Messenger: it
508
+ delivers quick replies only on a plain-text message, so `quick_replies` with media or with
509
+ `buttons` returns `422` on Instagram while both are accepted on Facebook.
510
+
511
+ Validation happens server-side and names the offending index — `buttons[1].url must be an
512
+ https:// URL` — surfacing as the SDK's usual error for a `422`.
513
+
514
+ > The new params are sent on the JSON path only. To combine quick replies with an
515
+ > attachment, pass `media` as a hosted URL rather than uploading via `media_files`.
516
+
517
+ A tap comes back as an **inbound message** carrying `tapped_action`:
518
+
519
+ ```ruby
520
+ inbound = client.messages.list(chat.id, direction: "inbound")
521
+ inbound.data.each do |msg|
522
+ next unless msg.tapped_action
523
+
524
+ # kind: "quick_reply", "postback", or "callback_query"
525
+ puts "#{msg.tapped_action.kind}: #{msg.tapped_action.payload}"
526
+ end
527
+ ```
528
+
529
+ Subscribe to `message.received` to react to taps as they happen — the same field is on the
530
+ webhook payload. `tapped_action` is derived rather than stored, so it also resolves for
531
+ taps recorded before PostProxy exposed it, including Instagram ice-breaker taps and
532
+ Telegram callback queries (`kind` `"callback_query"`). A tap also opens the 24h window.
533
+
396
534
  ## Profile comments (Google Business reviews)
397
535
 
398
536
  Profile-level comments expose Google Business reviews and replies. Reviews are user-generated — the SDK lets you list/get them and reply to or delete your own replies. Reviews sync twice daily.
@@ -466,6 +604,81 @@ bsky = client.profiles.get_profile_stats("prof_bsky_001")
466
604
  puts bsky.data.records.last.stats[:followersCount]
467
605
  ```
468
606
 
607
+ Every stats record (post stats and profile stats alike) carries `raw_stats` alongside the
608
+ normalized `stats`, exposing each metric under its **original platform name**:
609
+
610
+ ```ruby
611
+ stats = client.posts.stats(["post-id"])
612
+ record = stats.data["post-id"].platforms.first.records.first
613
+
614
+ puts record.stats[:impressions] # normalized
615
+ puts record.raw_stats[:views] # Instagram's own name
616
+ puts record.raw_stats[:impression_count] # Twitter/X's own name
617
+ ```
618
+
619
+ LinkedIn post stats now normalize `likes`, `comments`, `shares`, and `clicks` alongside
620
+ `impressions` — previously only `impressions` was normalized.
621
+
622
+ ### Post syncs & backfill
623
+
624
+ PostProxy mirrors posts published natively on a platform into your account. Every one of
625
+ those pulls is recorded as a **post sync**: the one fired when the profile connects, the
626
+ recurring poll, and any backfill you start.
627
+
628
+ ```ruby
629
+ # Start a backfill — walks the feed backwards from the newest post in batches
630
+ # of 25 until it reaches `from` or the platform stops returning posts.
631
+ sync = client.profiles.backfill_posts("prof-id", from: "2025-01-01")
632
+ puts sync.id, sync.status # => "sync456def" "pending"
633
+
634
+ # Poll it to completion — finished when status is "completed" or "failed"
635
+ run = client.profiles.post_sync("prof-id", sync.id)
636
+ puts "#{run.posts_imported} of #{run.posts_seen}, back to #{run.oldest_posted_at}"
637
+
638
+ # List recent runs (kept for 30 days), newest first
639
+ runs = client.profiles.post_syncs("prof-id",
640
+ trigger: "backfill", # connect | scheduled | backfill
641
+ status: "completed", # pending | running | completed | failed
642
+ per_page: 25
643
+ )
644
+ ```
645
+
646
+ | `PostSync` field | Description |
647
+ |---|---|
648
+ | `id` | Sync identifier |
649
+ | `profile_id` | Profile this run belongs to |
650
+ | `kind` | Always `posts` today |
651
+ | `trigger` | `connect`, `scheduled`, or `backfill` |
652
+ | `status` | `pending`, `running`, `completed`, or `failed` |
653
+ | `started_at` / `completed_at` | `Time` or `nil` |
654
+ | `posts_seen` | Posts the platform returned across the run |
655
+ | `posts_imported` | Posts that were **new** and got created |
656
+ | `backfill_from` | The date floor requested; `nil` for `connect`/`scheduled` |
657
+ | `oldest_posted_at` | Publish date of the oldest post the run reached |
658
+ | `error` | Platform error message when `status` is `"failed"` |
659
+ | `created_at` | `Time` |
660
+
661
+ **How far back a backfill reaches depends on the platform's API**, not on PostProxy: where
662
+ history is pageable we follow it, otherwise the run ends early with whatever it got and
663
+ still reports `status == "completed"`.
664
+
665
+ Only one backfill runs per profile at a time — starting a second raises `ConflictError`
666
+ carrying the running one's id:
667
+
668
+ ```ruby
669
+ begin
670
+ client.profiles.backfill_posts("prof-id", from: "2025-01-01")
671
+ rescue PostProxy::ConflictError => e
672
+ running_id = e.response[:profile_sync_id]
673
+ # Poll the run that's already going.
674
+ end
675
+ ```
676
+
677
+ Posts you already have are skipped, so overlapping backfills are safe. Imported posts
678
+ behave exactly like ones the poll picks up (`source: "imported"`, `post.imported`
679
+ webhook), but a backfill's follow-up work is queued at a lower priority so a deep run
680
+ can't slow down publishing.
681
+
469
682
  ## Profile Groups
470
683
 
471
684
  ```ruby
@@ -560,6 +773,43 @@ post = client.posts.create(
560
773
 
561
774
  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
775
 
776
+ ### Instagram user tags
777
+
778
+ Tag public Instagram accounts in a post — feed post, reel, or story:
779
+
780
+ ```ruby
781
+ client.posts.create(
782
+ "Shot on location",
783
+ profiles: ["ig-profile-id"],
784
+ media: [
785
+ "https://example.com/1.jpg",
786
+ "https://example.com/2.jpg",
787
+ "https://example.com/3.mp4"
788
+ ],
789
+ platforms: PostProxy::PlatformParams.new(
790
+ instagram: PostProxy::InstagramParams.new(
791
+ format: "post",
792
+ user_tags: [
793
+ { username: "natgeo", x: 0.5, y: 0.4 }, # slide 0
794
+ { username: "nasa", x: 0.2, y: 0.8, media_index: 1 }, # slide 1
795
+ { username: "spacex", media_index: 2 } # video — username only
796
+ ]
797
+ )
798
+ )
799
+ )
800
+ ```
801
+
802
+ - **Images require `x` and `y`** — floats `0.0`–`1.0` measured from the top-left corner.
803
+ - **Reels and video slides** are tagged by username only; coordinates are ignored and dropped.
804
+ - **Stories** accept coordinates but don't need them.
805
+ - `media_index` picks the carousel slide (0-based, defaults to `0`, video slides included).
806
+ - A leading `@` on a username is stripped for you.
807
+
808
+ Coordinates outside `0.0`–`1.0`, a `media_index` past the last media item, or an image tag
809
+ missing `x`/`y` are rejected with a `ValidationError` naming the offending entry. Accounts
810
+ that are private or have tagging turned off are silently skipped by Instagram at publish
811
+ time.
812
+
563
813
  ### Google Business
564
814
 
565
815
  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 +839,10 @@ rescue PostProxy::AuthenticationError => e
589
839
  puts "Auth failed: #{e.message}" # 401
590
840
  rescue PostProxy::NotFoundError => e
591
841
  puts "Not found: #{e.message}" # 404
842
+ rescue PostProxy::ConflictError => e
843
+ puts "Conflict: #{e.message}" # 409
844
+ puts e.response[:duplicate_post_id] # on a duplicate post
845
+ puts e.response[:profile_sync_id] # on a backfill already running
592
846
  rescue PostProxy::ValidationError => e
593
847
  puts "Invalid: #{e.message}" # 422
594
848
  rescue PostProxy::BadRequestError => e
@@ -599,6 +853,15 @@ rescue PostProxy::Error => e
599
853
  end
600
854
  ```
601
855
 
856
+ | Status | Error | Raised for |
857
+ |---|---|---|
858
+ | 400 | `BadRequestError` | Missing required parameters |
859
+ | 401 | `AuthenticationError` | Invalid, missing, or insufficient API key permissions |
860
+ | 404 | `NotFoundError` | Resource does not exist or is not accessible |
861
+ | 409 | `ConflictError` | Duplicate submission, a backfill already running, or an in-flight `Idempotency-Key` |
862
+ | 422 | `ValidationError` | Validation failed |
863
+ | 429 | `Error` | Posting rate limit reached |
864
+
602
865
  ## License
603
866
 
604
867
  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
@@ -22,8 +22,16 @@ module PostProxy
22
22
  )
23
23
  end
24
24
 
25
+ # quick_replies, buttons, and card are Facebook and Instagram only — they
26
+ # return 422 on Telegram and Bluesky, where reply_markup is the
27
+ # equivalent. They are sent on the JSON path only, so pass media as hosted
28
+ # URLs rather than media_files when combining with an attachment.
29
+ #
30
+ # Each accepts model instances or plain hashes.
25
31
  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)
32
+ reply_to_external_id: nil, reply_markup: nil,
33
+ quick_replies: nil, buttons: nil, card: nil,
34
+ profile_group_id: nil, idempotency_key: nil)
27
35
  has_files = media_files && !media_files.empty?
28
36
 
29
37
  if has_files
@@ -47,7 +55,8 @@ module PostProxy
47
55
  result = @client.request(:post, "/chats/#{chat_id}/messages",
48
56
  data: form_data,
49
57
  files: files,
50
- profile_group_id: profile_group_id
58
+ profile_group_id: profile_group_id,
59
+ idempotency_key: idempotency_key
51
60
  )
52
61
  else
53
62
  json_body = {}
@@ -56,8 +65,15 @@ module PostProxy
56
65
  json_body[:tag] = tag if tag
57
66
  json_body[:reply_to_external_id] = reply_to_external_id if reply_to_external_id
58
67
  json_body[:reply_markup] = reply_markup if reply_markup
68
+ json_body[:quick_replies] = quick_replies.map { |q| serialize_interactive(q) } if quick_replies
69
+ json_body[:buttons] = buttons.map { |b| serialize_interactive(b) } if buttons
70
+ json_body[:card] = serialize_interactive(card) if card
59
71
 
60
- result = @client.request(:post, "/chats/#{chat_id}/messages", json: json_body, profile_group_id: profile_group_id)
72
+ result = @client.request(:post, "/chats/#{chat_id}/messages",
73
+ json: json_body,
74
+ profile_group_id: profile_group_id,
75
+ idempotency_key: idempotency_key
76
+ )
61
77
  end
62
78
 
63
79
  Message.new(**result)
@@ -69,34 +85,49 @@ module PostProxy
69
85
  Message.new(**result)
70
86
  end
71
87
 
72
- def edit(message_id, body: nil, reply_markup: nil, profile_group_id: nil)
88
+ def edit(message_id, body: nil, reply_markup: nil, profile_group_id: nil, idempotency_key: nil)
73
89
  json_body = {}
74
90
  json_body[:body] = body if body
75
91
  json_body[:reply_markup] = reply_markup if reply_markup
76
92
 
77
- result = @client.request(:patch, "/messages/#{message_id}", json: json_body, profile_group_id: profile_group_id)
93
+ result = @client.request(:patch, "/messages/#{message_id}",
94
+ json: json_body,
95
+ profile_group_id: profile_group_id,
96
+ idempotency_key: idempotency_key
97
+ )
78
98
  Message.new(**result)
79
99
  end
80
100
 
81
- def react(message_id, reaction: nil, emoji: nil, profile_group_id: nil)
101
+ def react(message_id, reaction: nil, emoji: nil, profile_group_id: nil, idempotency_key: nil)
82
102
  json_body = {}
83
103
  json_body[:reaction] = reaction if reaction
84
104
  json_body[:emoji] = emoji if emoji
85
105
 
86
106
  result = @client.request(:post, "/messages/#{message_id}/react",
87
107
  json: json_body.empty? ? nil : json_body,
88
- profile_group_id: profile_group_id
108
+ profile_group_id: profile_group_id,
109
+ idempotency_key: idempotency_key
89
110
  )
90
111
  Message.new(**result)
91
112
  end
92
113
 
93
- def unreact(message_id, profile_group_id: nil)
94
- result = @client.request(:delete, "/messages/#{message_id}/unreact", profile_group_id: profile_group_id)
114
+ def unreact(message_id, profile_group_id: nil, idempotency_key: nil)
115
+ result = @client.request(:delete, "/messages/#{message_id}/unreact",
116
+ profile_group_id: profile_group_id,
117
+ idempotency_key: idempotency_key
118
+ )
95
119
  Message.new(**result)
96
120
  end
97
121
 
98
122
  private
99
123
 
124
+ # Interactive params accept model instances or plain hashes. Models expose
125
+ # to_h with nils dropped, so an omitted content_type stays omitted rather
126
+ # than being sent as null.
127
+ def serialize_interactive(value)
128
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
129
+ end
130
+
100
131
  def mime_type_for(filename)
101
132
  case File.extname(filename).downcase
102
133
  when ".jpg", ".jpeg" then "image/jpeg"
@@ -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)
@@ -426,11 +429,94 @@ module PostProxy
426
429
  end
427
430
  end
428
431
 
432
+ # A tappable chip above the participant's composer, gone once tapped.
433
+ # Facebook and Instagram only; up to 13 per send. +content_type+ is optional
434
+ # on send (only "text" is accepted) and always present on responses.
435
+ class QuickReply < Model
436
+ attr_accessor :content_type, :title, :payload
437
+
438
+ def initialize(**attrs)
439
+ @content_type = nil
440
+ super
441
+ end
442
+
443
+ def to_h
444
+ { content_type: @content_type, title: @title, payload: @payload }.compact
445
+ end
446
+ end
447
+
448
+ # A button attached to the message, delivered as a Meta generic template.
449
+ # Facebook and Instagram only; up to 3 per send. +url+ is required and must be
450
+ # https when +type+ is "web_url"; +payload+ is required when +type+ is
451
+ # "postback". +type+ is a plain string rather than an enum so a new Meta
452
+ # button type needs no SDK release.
453
+ class MessageButton < Model
454
+ attr_accessor :type, :title, :url, :payload
455
+
456
+ def initialize(**attrs)
457
+ @url = nil
458
+ @payload = nil
459
+ super
460
+ end
461
+
462
+ def to_h
463
+ { type: @type, title: @title, url: @url, payload: @payload }.compact
464
+ end
465
+ end
466
+
467
+ class CardDefaultAction < Model
468
+ attr_accessor :type, :url
469
+
470
+ def to_h
471
+ { type: @type, url: @url }.compact
472
+ end
473
+ end
474
+
475
+ # Extra fields for the generic-template element that carries +buttons+.
476
+ # Requires +buttons+. +subtitle+ is capped at 80 characters, and both URLs
477
+ # must be https.
478
+ class MessageCard < Model
479
+ attr_accessor :subtitle, :image_url, :default_action
480
+
481
+ def initialize(**attrs)
482
+ @subtitle = nil
483
+ @image_url = nil
484
+ @default_action = nil
485
+ super
486
+ if @default_action && !@default_action.is_a?(CardDefaultAction)
487
+ @default_action = CardDefaultAction.new(**@default_action.transform_keys(&:to_sym))
488
+ end
489
+ end
490
+
491
+ def to_h
492
+ {
493
+ subtitle: @subtitle,
494
+ image_url: @image_url,
495
+ default_action: @default_action&.to_h
496
+ }.compact
497
+ end
498
+ end
499
+
500
+ # Set on inbound messages created by a tap on an element you sent. Derived
501
+ # from platform_data rather than stored, so it also resolves for taps ingested
502
+ # before PostProxy exposed this field. +kind+ is one of "quick_reply",
503
+ # "postback", or "callback_query" — the last is Telegram, so this is not
504
+ # Meta-only even though the send params are.
505
+ class TappedAction < Model
506
+ attr_accessor :kind, :payload, :title
507
+
508
+ def initialize(**attrs)
509
+ @title = nil
510
+ super
511
+ end
512
+ end
513
+
429
514
  class Message < Model
430
515
  attr_accessor :id, :chat_id, :external_id, :direction, :body, :status,
431
516
  :tag, :external_comment_id, :error_message, :platform_data,
432
517
  :external_posted_at, :external_delivered_at, :external_read_at,
433
518
  :external_edited_at, :reply_to_external_id, :reply_markup,
519
+ :quick_replies, :buttons, :card, :tapped_action,
434
520
  :external_deleted_at, :reactions, :attachments,
435
521
  :is_unsupported, :created_at
436
522
 
@@ -447,6 +533,10 @@ module PostProxy
447
533
  @external_edited_at = nil
448
534
  @reply_to_external_id = nil
449
535
  @reply_markup = nil
536
+ @quick_replies = nil
537
+ @buttons = nil
538
+ @card = nil
539
+ @tapped_action = nil
450
540
  @external_deleted_at = nil
451
541
  @reactions = []
452
542
  @attachments = []
@@ -464,6 +554,20 @@ module PostProxy
464
554
  @attachments = (@attachments || []).map do |a|
465
555
  a.is_a?(Attachment) ? a : Attachment.new(**a.transform_keys(&:to_sym))
466
556
  end
557
+ # Left nil rather than [] when absent — the API omits these on non-Meta
558
+ # networks, and an empty array would read as "sent with none".
559
+ @quick_replies = @quick_replies&.map do |q|
560
+ q.is_a?(QuickReply) ? q : QuickReply.new(**q.transform_keys(&:to_sym))
561
+ end
562
+ @buttons = @buttons&.map do |b|
563
+ b.is_a?(MessageButton) ? b : MessageButton.new(**b.transform_keys(&:to_sym))
564
+ end
565
+ if @card && !@card.is_a?(MessageCard)
566
+ @card = MessageCard.new(**@card.transform_keys(&:to_sym))
567
+ end
568
+ if @tapped_action && !@tapped_action.is_a?(TappedAction)
569
+ @tapped_action = TappedAction.new(**@tapped_action.transform_keys(&:to_sym))
570
+ end
467
571
  end
468
572
 
469
573
  private
@@ -505,6 +609,79 @@ module PostProxy
505
609
  attr_accessor :accepted
506
610
  end
507
611
 
612
+ # A comment from Comments#list_all. Flat: replies are their own entries
613
+ # linked to their parent by `parent_external_id` rather than nested under
614
+ # `replies`.
615
+ class BulkComment < Model
616
+ attr_accessor :post_id, :profile_id, :platform, :id, :external_id, :body,
617
+ :status, :author_username, :author_avatar_url,
618
+ :author_external_id, :metadata, :parent_external_id,
619
+ :like_count, :is_hidden, :permalink, :platform_data,
620
+ :attachments, :posted_at, :created_at
621
+
622
+ def initialize(**attrs)
623
+ @external_id = nil
624
+ @author_username = nil
625
+ @author_avatar_url = nil
626
+ @author_external_id = nil
627
+ @metadata = nil
628
+ @parent_external_id = nil
629
+ @like_count = 0
630
+ @is_hidden = false
631
+ @permalink = nil
632
+ @platform_data = nil
633
+ @attachments = []
634
+ @posted_at = nil
635
+ super
636
+ @posted_at = parse_time(@posted_at)
637
+ @created_at = parse_time(@created_at)
638
+ @attachments = (@attachments || []).map do |a|
639
+ a.is_a?(Attachment) ? a : Attachment.new(**a.transform_keys(&:to_sym))
640
+ end
641
+ end
642
+
643
+ private
644
+
645
+ def parse_time(value)
646
+ return nil if value.nil?
647
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
648
+ end
649
+ end
650
+
651
+ # A record of one post pull for a profile — the sync fired when the profile
652
+ # connects, the recurring poll, or a backfill.
653
+ class PostSync < Model
654
+ attr_accessor :id, :profile_id, :kind, :trigger, :status, :started_at,
655
+ :completed_at, :posts_seen, :posts_imported, :backfill_from,
656
+ :oldest_posted_at, :error, :created_at
657
+
658
+ def initialize(**attrs)
659
+ @started_at = nil
660
+ @completed_at = nil
661
+ @posts_seen = 0
662
+ # Posts that were new and got created — lower than `posts_seen` whenever
663
+ # the run re-read posts you already have.
664
+ @posts_imported = 0
665
+ @backfill_from = nil
666
+ # Publish date of the oldest post the run reached.
667
+ @oldest_posted_at = nil
668
+ @error = nil
669
+ super
670
+ @started_at = parse_time(@started_at)
671
+ @completed_at = parse_time(@completed_at)
672
+ @backfill_from = parse_time(@backfill_from)
673
+ @oldest_posted_at = parse_time(@oldest_posted_at)
674
+ @created_at = parse_time(@created_at)
675
+ end
676
+
677
+ private
678
+
679
+ def parse_time(value)
680
+ return nil if value.nil?
681
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
682
+ end
683
+ end
684
+
508
685
  class DeleteResponse < Model
509
686
  attr_accessor :deleted
510
687
  end
@@ -589,9 +766,37 @@ module PostProxy
589
766
  attr_accessor :format, :title, :first_comment, :page_id
590
767
  end
591
768
 
769
+ # An Instagram account to tag in a post. Images require `x` and `y`; reels
770
+ # and video slides are tagged by username only — Instagram ignores
771
+ # coordinates there. `media_index` picks the carousel slide (0-based).
772
+ class InstagramUserTag < Model
773
+ attr_accessor :username, :x, :y, :media_index
774
+ end
775
+
592
776
  class InstagramParams < Model
593
777
  attr_accessor :format, :first_comment, :collaborators, :cover_url,
594
- :audio_name, :trial_strategy, :thumb_offset
778
+ :audio_name, :trial_strategy, :thumb_offset, :user_tags
779
+
780
+ def initialize(**attrs)
781
+ @user_tags = nil
782
+ super
783
+ @user_tags = @user_tags&.map do |t|
784
+ t.is_a?(InstagramUserTag) ? t : InstagramUserTag.new(**t.transform_keys(&:to_sym))
785
+ end
786
+ end
787
+
788
+ # User tags must reach the wire as plain hashes, with unset coordinates
789
+ # dropped rather than sent as nulls.
790
+ def to_h
791
+ result = super
792
+ tags = result[:user_tags]
793
+ return result if tags.nil?
794
+
795
+ result[:user_tags] = tags.map do |t|
796
+ (t.is_a?(Model) ? t.to_h : t).reject { |_, v| v.nil? }
797
+ end
798
+ result
799
+ end
595
800
  end
596
801
 
597
802
  class TikTokParams < Model
@@ -1,3 +1,3 @@
1
1
  module PostProxy
2
- VERSION = "1.11.0"
2
+ VERSION = "1.13.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.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - PostProxy