instagram_connect 0.3.0 → 0.3.2

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: bfea76e8f86d1da429ba916fdf03124bb3eb0a4f0cc8d50bdeb5db838282026a
4
- data.tar.gz: b55274a8583dcad12bedca7025517fbfcea36865479f28c520c7c103e8e4d49a
3
+ metadata.gz: c6cdab9c7a77b05738f216055248fb49663ce358ab496612c37be3b065b329dc
4
+ data.tar.gz: 2ff8e7866abc44e383509240659cb2403f1e1ce803b55d3cbbf6d28fc7fe394c
5
5
  SHA512:
6
- metadata.gz: b9d2f5d664868d9444883e603684bf22e65880d8e75ef65d6014c14a7a6065cdf5d199b1d77c6c3c2f3178c7c804ebc921cdf08966d6c68b2ee18686a1c3a3f4
7
- data.tar.gz: d21363ec67aa080df5ff76747cf6f97db0b81963fe66716e7df44e44456dab3cf5b528e502f43c441a967a8d029459d2bb1dcef343b215979524bcc9c068bbbe
6
+ metadata.gz: d2dc22ae8f6c88e3a7f3ab210f3211a884c65fc0ea688e10dd9da63377b8fe91b21bb419420e875634783844e0daa84c9c6e458d0344fba6de4d11f35c1eaba3
7
+ data.tar.gz: 8dc40c7345f8a292e4370977c1233f46a77f7a551c0ebfd55db89fd30a769fd453023a5229bfaf9df43a47ae788202b5a0a9cab7bc62d52916604a6410b1b675
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] - 2026-07-26
10
+
11
+ ### Added
12
+
13
+ - **Existing conversations are now imported.** The gem only ever created threads from inbound
14
+ webhooks, and webhooks only announce NEW activity — so a freshly connected account showed an
15
+ empty inbox beside an Instagram account full of conversations, and stayed empty until somebody
16
+ happened to message in. `SyncConversationsJob` pulls every thread and the 20 most recent messages
17
+ in each, which is the whole history Meta exposes to any app. It runs on connect and is safe to
18
+ re-run: threads are located by igsid and messages dedupe on `ig_message_id`.
19
+ - `Client#list_conversations` and `Client#conversation_messages`.
20
+
21
+ ### Note
22
+
23
+ The Conversations API is a **Page-token** operation. An account still holding the OAuth user token
24
+ gets `(#3) Application does not have the capability to make this API call` — verified against a live
25
+ account. `AccountReadinessJob` performs that swap, so it must have run before a sync can succeed.
26
+
27
+
28
+ ## [0.3.1] - 2026-07-26
29
+
30
+ ### Fixed
31
+
32
+ - **Connecting an account failed when the Page came from the Login-for-Business asset picker.**
33
+ `/me/accounts` lists only the Pages a token may *enumerate*. A Page granted through the picker is
34
+ absent from that listing while reading perfectly well by id, so the list came back empty and the
35
+ connect was refused — on an account that was correctly linked and fully granted. Identity
36
+ resolution now falls back to the assets the token actually carries (`/debug_token` granular
37
+ scopes, which needs no permission beyond the app's own credentials) and reads those Pages by id.
38
+ - **A failed Graph call was reported as a missing account.** `resolve_identity` never checked
39
+ `Result#success?`, so an expired token or an HTTP 400 produced the same message as an empty list.
40
+ API failures now raise `ApiError` carrying Meta's own message.
41
+ - **The error message asserted a cause it had not verified.** It claimed no Instagram account was
42
+ linked to a Page, which was false in exactly the case that triggered it. It now states what was
43
+ actually checked.
44
+ - **`AccountReadinessJob` had the same flaw**, hunting for a known Page inside `/me/accounts`
45
+ instead of reading it by id — so the Page-token swap and the webhook subscription failed for the
46
+ same accounts, silently.
47
+
48
+
9
49
  ## [0.3.0] - 2026-07-26
10
50
 
11
51
  Conversations and engagement. The gem parsed 2 of the 15 webhook fields Meta can
@@ -87,8 +87,10 @@ module InstagramConnect
87
87
  return
88
88
  end
89
89
 
90
- pages = graph_get(account, "/me/accounts", fields: "id,access_token")
91
- page = Array(pages["data"]).find { |p| p["id"].to_s == account.page_id.to_s }
90
+ # Read the Page directly rather than hunting for it in /me/accounts: we
91
+ # already know its id, and the listing omits Pages granted through the
92
+ # Login-for-Business asset picker even though they read fine by id.
93
+ page = graph_get(account, "/#{account.page_id}", fields: "id,access_token")
92
94
 
93
95
  if page.nil? || page["access_token"].blank?
94
96
  account.update!(needs_reconnect: true,
@@ -0,0 +1,81 @@
1
+ module InstagramConnect
2
+ # Pulls the threads that already exist on the account.
3
+ #
4
+ # Webhooks only ever announce NEW activity, so a freshly connected account
5
+ # shows an empty inbox until somebody happens to message in — even though the
6
+ # operator has years of conversations sitting in Instagram. This fetches what
7
+ # Meta will give us: every thread, and the 20 most recent messages in each,
8
+ # which is the entire history the API exposes to any app.
9
+ #
10
+ # Safe to re-run. Threads are located by igsid and messages are deduped on
11
+ # ig_message_id, so a second pass updates rather than duplicates.
12
+ class SyncConversationsJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ def perform(account_id)
16
+ account = Account.find_by(id: account_id)
17
+ return if account.nil? || !account.active?
18
+
19
+ threads = account.client.list_conversations
20
+ return unless threads.success?
21
+
22
+ Array(threads.data["data"]).each { |thread| sync_thread(account, thread["id"]) }
23
+ end
24
+
25
+ private
26
+
27
+ def sync_thread(account, conversation_id)
28
+ result = account.client.conversation_messages(conversation_id: conversation_id)
29
+ return unless result.success?
30
+
31
+ messages = Array(result.data.dig("messages", "data"))
32
+ # A thread with no readable messages tells us nothing and would show as an
33
+ # empty row in the inbox, so it is skipped rather than created.
34
+ return if messages.empty?
35
+
36
+ conversation = Conversation.locate(account: account, igsid: counterpart(messages, account))
37
+ messages.reverse_each { |message| import(conversation, account, message) }
38
+ end
39
+
40
+ # Meta reports both ends of every message; the thread belongs to whichever
41
+ # participant is not us.
42
+ def counterpart(messages, account)
43
+ messages.each do |message|
44
+ [ message.dig("from", "id"), *Array(message.dig("to", "data")).map { |t| t["id"] } ]
45
+ .compact.each { |id| return id.to_s if id.to_s != account.ig_user_id.to_s }
46
+ end
47
+ account.ig_user_id.to_s
48
+ end
49
+
50
+ def import(conversation, account, payload)
51
+ mid = payload["id"].to_s
52
+ return if mid.blank?
53
+
54
+ message = Message.find_or_initialize_by(conversation_id: conversation.id, ig_message_id: mid)
55
+ return unless message.new_record?
56
+
57
+ outbound = payload.dig("from", "id").to_s == account.ig_user_id.to_s
58
+ message.assign_attributes(
59
+ direction: outbound ? "outbound" : "inbound",
60
+ # Historical rows: an outbound message we are reading back has already
61
+ # been delivered, and an inbound one has already arrived.
62
+ status: outbound ? "sent" : "received",
63
+ body: payload["message"],
64
+ source: "sync",
65
+ sent_at: parse_time(payload["created_time"])
66
+ )
67
+ message.account_id = account.id
68
+ message.save!
69
+ conversation.register_message(message)
70
+ rescue ActiveRecord::RecordNotUnique
71
+ # Raced a webhook for the same message. The webhook's copy is the one that
72
+ # matters; ours would have been identical.
73
+ end
74
+
75
+ def parse_time(value)
76
+ Time.zone.parse(value.to_s)
77
+ rescue ArgumentError, TypeError
78
+ nil
79
+ end
80
+ end
81
+ end
@@ -157,6 +157,40 @@ module InstagramConnect
157
157
  get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" })
158
158
  end
159
159
 
160
+ # Threads that already exist on the account. Webhooks only ever tell us about
161
+ # NEW activity, so without this an inbox starts empty and stays empty until
162
+ # somebody happens to message in. Meta returns the 20 most recent messages
163
+ # per thread and no more — that is the whole history available to any app.
164
+ def list_conversations(ig_user_id: @ig_user_id, limit: 50)
165
+ collect("/#{require_ig_user_id(ig_user_id)}/conversations",
166
+ { platform: "instagram", fields: "id,updated_time", limit: limit })
167
+ end
168
+
169
+ def conversation_messages(conversation_id:, limit: 20)
170
+ get("/#{conversation_id}",
171
+ { fields: "messages.limit(#{limit}){id,created_time,from,to,message}" })
172
+ end
173
+
174
+ # One Page by id. /me/accounts answers for the Pages a token may *enumerate*;
175
+ # this answers for a Page it may *read*. Those are not the same set — a Page
176
+ # granted through the Login-for-Business asset picker is readable here while
177
+ # absent from the listing.
178
+ def page(page_id)
179
+ get("/#{page_id}", { fields: "id,name,access_token,instagram_business_account" })
180
+ end
181
+
182
+ # The asset ids this token actually carries, from Meta's own record of the
183
+ # consent. Needs no permission beyond the app's own credentials.
184
+ def granted_asset_ids
185
+ result = get("/debug_token", { input_token: access_token,
186
+ access_token: "#{config.app_id}|#{config.app_secret}" })
187
+ return [] unless result.success?
188
+
189
+ Array(result.data.dig("data", "granular_scopes"))
190
+ .flat_map { |scope| Array(scope["target_ids"]) }
191
+ .uniq
192
+ end
193
+
160
194
  # Walks a Graph collection to the end, following Meta's own cursors.
161
195
  #
162
196
  # Offsets are not safe here: a collection can shift between calls, so an
@@ -25,6 +25,10 @@ module InstagramConnect
25
25
  )
26
26
  account.connected_by_id = connected_by_id unless connected_by_id.nil?
27
27
  account.save!
28
+ # Webhooks only announce new activity, so without this the operator
29
+ # connects and stares at an empty inbox beside an Instagram account full
30
+ # of conversations.
31
+ SyncConversationsJob.perform_later(account.id)
28
32
  account
29
33
  end
30
34
 
@@ -35,11 +39,34 @@ module InstagramConnect
35
39
  def resolve_identity(data)
36
40
  return { ig_user_id: data[:ig_user_id], page_id: data[:page_id] } if data[:ig_user_id]
37
41
 
38
- result = Client.new(access_token: data[:access_token], config: @config).list_pages
39
- page = Array(result.data["data"]).find { |p| p["instagram_business_account"] }
40
- raise ConfigurationError, "no Instagram business account is linked to a Page" unless page
42
+ client = Client.new(access_token: data[:access_token], config: @config)
43
+ page = candidate_pages(client).find { |p| p["instagram_business_account"] }
44
+ unless page
45
+ raise ConfigurationError,
46
+ "connected, but no Page with a linked Instagram business account was found — " \
47
+ "checked the Pages this token can list and the assets it was granted"
48
+ end
41
49
 
42
- { ig_user_id: page.dig("instagram_business_account", "id"), page_id: page["id"] }
50
+ { ig_user_id: page.dig("instagram_business_account", "id").to_s, page_id: page["id"].to_s }
51
+ end
52
+
53
+ # /me/accounts lists only the Pages a token may enumerate, and a Page granted
54
+ # through the Login-for-Business asset picker is not one of them — the list
55
+ # comes back empty while the Page itself reads fine. So when the listing has
56
+ # nothing usable, ask the token which assets it carries and read those.
57
+ def candidate_pages(client)
58
+ listed = client.list_pages
59
+ unless listed.success?
60
+ raise ApiError, "could not read Pages from Instagram: #{listed.error_message}"
61
+ end
62
+
63
+ pages = Array(listed.data["data"])
64
+ return pages if pages.any? { |p| p["instagram_business_account"] }
65
+
66
+ client.granted_asset_ids.filter_map do |id|
67
+ result = client.page(id)
68
+ result.data if result.success?
69
+ end
43
70
  end
44
71
  end
45
72
  end
@@ -1,3 +1,3 @@
1
1
  module InstagramConnect
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.2"
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.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kshitiz Sinha
@@ -100,6 +100,7 @@ files:
100
100
  - app/jobs/instagram_connect/refresh_tokens_job.rb
101
101
  - app/jobs/instagram_connect/replay_events_job.rb
102
102
  - app/jobs/instagram_connect/send_message_job.rb
103
+ - app/jobs/instagram_connect/sync_conversations_job.rb
103
104
  - app/models/instagram_connect/account.rb
104
105
  - app/models/instagram_connect/api_budget.rb
105
106
  - app/models/instagram_connect/application_record.rb