instagram_connect 0.3.8 → 0.3.9

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: e92666f3e02d64943e955f8e20162754255471dc92bd4fc2e54e3f6db3a68111
4
- data.tar.gz: 4bc700ffa63d750b2ad1313d1015e8f799edc44e56c9520d6b7be10fd494631c
3
+ metadata.gz: 3a9c934d89a0e97c513a99c5352da9227b5133f1011557deb0ea87f4d8a6ce9d
4
+ data.tar.gz: a7b0af0a9b85bbeaf6c3015f45f3a467c9f7a23b5f73356ba34819389a887190
5
5
  SHA512:
6
- metadata.gz: 0ba66b7cabad11d19321d2efa789728a97f5b668fb84c05bf3bf4c80277ab1df483e2e37ad85b87d3c6e3f6a0878a80608550e8ffb91154fb8f2e27f5490c698
7
- data.tar.gz: de44cfd0f48eeafa8c4d2556d4f72de17c18218f2bba8ae9da9d8fa5f8bc67ceb41a840d8412f7a05181517288afd2c5fd92ff40128d676c6318379d389c595c
6
+ metadata.gz: 35cca9a90ea1822ff47b9b6fbb09addb00c60a0484519842fdd54285b51cb0643f41f64105bd6f9a96e157637dbf6daa3f5e9b5c2ea4dabb07a6436eb9201dcd
7
+ data.tar.gz: bdb981d70acbe14a489d95b7ea8ea5c5229aa2462f2998aca1ad8328146e25dec95101c3583da7a77901da5c16429d385b1860366454b5b1c921d6396e1b5ad2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,28 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.9] - 2026-07-27
10
+
11
+ ### Added
12
+
13
+ - **Stories sync.** `SyncStoriesJob` walks the `/stories` edge and mirrors live stories as
14
+ `media_product_type: STORY` rows. Meta lists only the stories inside their 24 hours and sends no
15
+ expiry webhook, so the job upserts what it sees and marks nothing — expired stories keep their
16
+ rows forever, same never-delete rule as posts.
17
+ - **Carousel slides.** `MEDIA_FIELDS` now requests `children{id,media_type,media_url,thumbnail_url}`
18
+ and the new `children` json column stores them, so hosts can render every frame of a carousel,
19
+ not just the cover. `media_product_type` is requested too (FEED vs REELS vs STORY).
20
+ - **`MediaItem#live_story?`** — a story lives 24 hours from posting; unknown posted_at counts as expired.
21
+ - **`MediaItem.posts` scope** — everything that belongs on a feed grid. NULL-safe: rows synced
22
+ before `media_product_type` was requested still count as posts.
23
+ - `Client#list_stories`, and the media field lists now live on `Client`
24
+ (`Client::MEDIA_FIELDS` / `Client::STORY_FIELDS`).
25
+
26
+ ### Migration
27
+
28
+ - `add_column :instagram_connect_media, :children, :json` (`if_not_exists`).
29
+
30
+
9
31
  ## [0.3.8] - 2026-07-27
10
32
 
11
33
  ### Added
@@ -20,8 +20,7 @@ module InstagramConnect
20
20
  # like_count and comments_count come straight off the media node — no
21
21
  # insights permission needed for the account's own posts. thumbnail_url is
22
22
  # the poster frame for videos, whose media_url is a playable mp4.
23
- MEDIA_FIELDS = "id,caption,media_type,media_url,thumbnail_url,permalink," \
24
- "timestamp,like_count,comments_count".freeze
23
+ MEDIA_FIELDS = Client::MEDIA_FIELDS
25
24
 
26
25
  def self.enqueue_throttled(account)
27
26
  Rails.cache.fetch("instagram_connect:sync_media:#{account.id}", expires_in: THROTTLE) do
@@ -69,6 +68,8 @@ module InstagramConnect
69
68
  thumbnail_url: item["thumbnail_url"],
70
69
  like_count: item["like_count"],
71
70
  comments_count: item["comments_count"],
71
+ media_product_type: item["media_product_type"],
72
+ children: item.dig("children", "data"),
72
73
  posted_at: parse_time(item["timestamp"]),
73
74
  synced_at: Time.current
74
75
  )
@@ -0,0 +1,60 @@
1
+ module InstagramConnect
2
+ # Mirrors the account's stories. Meta's /stories edge returns only the
3
+ # stories still inside their 24 hours, and sends no webhook when one
4
+ # expires — so this walk upserts what it sees and marks nothing: a story
5
+ # that stops appearing has simply expired, which the row's posted_at
6
+ # already tells the host. Expired stories keep their rows forever, same
7
+ # never-delete rule as posts; their CDN URLs die with the story.
8
+ #
9
+ # Safe to re-run: MediaItem.record upserts on (account, ig_media_id).
10
+ class SyncStoriesJob < ApplicationJob
11
+ queue_as :default
12
+
13
+ # Hosts enqueue this from screens; the cache key stops a busy screen
14
+ # enqueueing it once per page view.
15
+ THROTTLE = 10.minutes
16
+
17
+ def self.enqueue_throttled(account)
18
+ Rails.cache.fetch("instagram_connect:sync_stories:#{account.id}", expires_in: THROTTLE) do
19
+ perform_later(account.id)
20
+ true
21
+ end
22
+ end
23
+
24
+ def perform(account_id)
25
+ account = Account.find_by(id: account_id)
26
+ return if account.nil? || !account.active?
27
+
28
+ result = account.client.list_stories
29
+ unless result.success?
30
+ return logger.error("[instagram_connect] stories sync failed for " \
31
+ "account=#{account.id}: #{result.error_message}")
32
+ end
33
+
34
+ Array(result.data["data"]).each { |item| upsert(account, item) }
35
+ end
36
+
37
+ private
38
+
39
+ def upsert(account, item)
40
+ MediaItem.record(
41
+ account: account,
42
+ ig_media_id: item["id"],
43
+ media_type: item["media_type"],
44
+ media_product_type: item["media_product_type"] || "STORY",
45
+ caption: item["caption"],
46
+ permalink: item["permalink"],
47
+ media_url: item["media_url"],
48
+ thumbnail_url: item["thumbnail_url"],
49
+ posted_at: parse_time(item["timestamp"]),
50
+ synced_at: Time.current
51
+ )
52
+ end
53
+
54
+ def parse_time(value)
55
+ Time.zone.parse(value.to_s)
56
+ rescue ArgumentError, TypeError
57
+ nil
58
+ end
59
+ end
60
+ end
@@ -17,6 +17,10 @@ module InstagramConnect
17
17
  validates :ig_media_id, presence: true, uniqueness: { scope: :account_id }
18
18
 
19
19
  scope :stories, -> { where(media_product_type: "STORY") }
20
+ # Everything that belongs on a feed grid. NULL-safe on purpose: rows
21
+ # synced before media_product_type was requested have nil there, and a
22
+ # plain where.not would silently drop them.
23
+ scope :posts, -> { where("media_product_type IS NULL OR media_product_type <> 'STORY'") }
20
24
  scope :recent, -> { order(Arel.sql("posted_at IS NULL, posted_at DESC")) }
21
25
 
22
26
  # Upserts by Meta's id. Media arrives from several directions — a webhook
@@ -40,5 +44,12 @@ module InstagramConnect
40
44
  def story?
41
45
  media_product_type == "STORY"
42
46
  end
47
+
48
+ # A story lives for 24 hours from posting; after that Instagram drops it
49
+ # and its CDN URLs die. Unknown posted_at counts as expired — better a
50
+ # grayed card than a player pointed at a dead URL.
51
+ def live_story?
52
+ story? && posted_at.present? && posted_at > 24.hours.ago
53
+ end
43
54
  end
44
55
  end
@@ -0,0 +1,9 @@
1
+ class AddChildrenToInstagramConnectMedia < ActiveRecord::Migration[8.0]
2
+ # The slides of a carousel post, as Meta returns them: an array of
3
+ # {id, media_type, media_url, thumbnail_url} hashes. json, not jsonb,
4
+ # because the gem runs on sqlite too. if_not_exists so a host that ran a
5
+ # copy of this migration before adopting the gem version is unaffected.
6
+ def change
7
+ add_column :instagram_connect_media, :children, :json, if_not_exists: true
8
+ end
9
+ end
@@ -131,11 +131,28 @@ module InstagramConnect
131
131
 
132
132
  # --- Reads -----------------------------------------------------------
133
133
 
134
+ # Everything the mirror stores about a post: engagement counts come straight
135
+ # off the media node (no insights permission needed for the account's own
136
+ # posts), thumbnail_url is the video poster frame, media_product_type tells
137
+ # FEED from REELS, and children are a carousel's slides.
138
+ MEDIA_FIELDS = "id,caption,media_type,media_url,thumbnail_url,permalink," \
139
+ "timestamp,like_count,comments_count,media_product_type," \
140
+ "children{id,media_type,media_url,thumbnail_url}".freeze
141
+
142
+ # A story is a media node too, but the engagement fields don't exist on it.
143
+ STORY_FIELDS = "id,caption,media_type,media_url,thumbnail_url,permalink," \
144
+ "timestamp,media_product_type".freeze
145
+
134
146
  def list_media(ig_user_id: @ig_user_id, limit: 25)
135
147
  get("/#{require_ig_user_id(ig_user_id)}/media",
136
- { fields: "id,caption,media_type,media_url,thumbnail_url,permalink," \
137
- "timestamp,like_count,comments_count",
138
- limit: limit })
148
+ { fields: MEDIA_FIELDS, limit: limit })
149
+ end
150
+
151
+ # The account's LIVE stories — Meta returns only the ones still inside
152
+ # their 24 hours. Expired stories simply stop appearing here.
153
+ def list_stories(ig_user_id: @ig_user_id, limit: 25)
154
+ get("/#{require_ig_user_id(ig_user_id)}/stories",
155
+ { fields: STORY_FIELDS, limit: limit })
139
156
  end
140
157
 
141
158
  def media_insights(media_id:, metrics: %w[reach likes comments])
@@ -1,3 +1,3 @@
1
1
  module InstagramConnect
2
- VERSION = "0.3.8"
2
+ VERSION = "0.3.9"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: instagram_connect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.8
4
+ version: 0.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -102,6 +102,7 @@ files:
102
102
  - app/jobs/instagram_connect/send_message_job.rb
103
103
  - app/jobs/instagram_connect/sync_conversations_job.rb
104
104
  - app/jobs/instagram_connect/sync_media_job.rb
105
+ - app/jobs/instagram_connect/sync_stories_job.rb
105
106
  - app/models/instagram_connect/account.rb
106
107
  - app/models/instagram_connect/api_budget.rb
107
108
  - app/models/instagram_connect/application_record.rb
@@ -145,6 +146,7 @@ files:
145
146
  - db/migrate/20260725204815_create_instagram_connect_api_budgets.rb
146
147
  - db/migrate/20260725205417_add_conversation_profile_fields.rb
147
148
  - db/migrate/20260727074500_add_removed_flag_to_instagram_connect_media.rb
149
+ - db/migrate/20260727110000_add_children_to_instagram_connect_media.rb
148
150
  - docs/CONFIGURATION.md
149
151
  - docs/app_review_guide.md
150
152
  - docs/auth_paths.md