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 +4 -4
- data/CHANGELOG.md +40 -0
- data/app/jobs/instagram_connect/account_readiness_job.rb +4 -2
- data/app/jobs/instagram_connect/sync_conversations_job.rb +81 -0
- data/lib/instagram_connect/client.rb +34 -0
- data/lib/instagram_connect/connect.rb +31 -4
- data/lib/instagram_connect/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6cdab9c7a77b05738f216055248fb49663ce358ab496612c37be3b065b329dc
|
|
4
|
+
data.tar.gz: 2ff8e7866abc44e383509240659cb2403f1e1ce803b55d3cbbf6d28fc7fe394c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
39
|
-
page =
|
|
40
|
-
|
|
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
|
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.
|
|
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
|