instagram_connect 0.3.1 → 0.3.3

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: 1d1a0dd271f5e3f830351ddd25d62794b512c7786f125b6aa2f5ea8167db1423
4
- data.tar.gz: 0e211865a1e788987b1fdb64f739bb2cde4c08ef57821b6b7602e23b22689cf3
3
+ metadata.gz: 610d72aa6d017bd24db1f7b670ae377a9d6375c74195f21d2992a4249daadc91
4
+ data.tar.gz: 453de0f0a35ee225997a54292260e2b40dcf302b32242fe05f108ef3a702917c
5
5
  SHA512:
6
- metadata.gz: 8df4d4149716e96900c767391f73c820b6777f0962c0f7e1d3c4e2522984f7af27da083ce75ada8092bd188dd9897053767e6c1fb2e7654291f6bff7a0c0ce66
7
- data.tar.gz: 97a67eb17002fd5611fb76f53fc6e5a852ed8d8a62875f07a367258fe16758fa16556194b82fa61eea270ca131649df78027821618f3aadfb6c9fa92e5f0d269
6
+ metadata.gz: 833aa49017299e66d1b2b6470ed32299ade4863cd8fd3393aa2790231af47e98c1e33412c200f6e7f5eec319d842b6cf4d5a27951cdf137223731caaa5ae435e
7
+ data.tar.gz: 62bf43bce6b59381057c3e26b0b201f529cac269eaadaf5497a61f5217d60858f8b2d6c223912aa0703fec3d415c011ba8a8bf4ac6edfd60fdb5927da3fa055e
data/CHANGELOG.md CHANGED
@@ -6,6 +6,39 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.3] - 2026-07-26
10
+
11
+ ### Fixed
12
+
13
+ - **Conversation sync imported nothing on the Facebook-Login path.** The conversations edge belongs
14
+ to the PAGE, not the Instagram user — `/{ig-user-id}/conversations` answers with a capability
15
+ error even when holding a valid Page token. The sync now targets the Page and falls back to the
16
+ Instagram user id only when there is no Page (the Instagram-Login path). Found in production,
17
+ where the job "succeeded" in 600ms twice while importing zero threads.
18
+ - **That failure was silent.** The job returned quietly when the listing call failed, so nothing —
19
+ logs, health, operator — ever learned why. It now logs the account, the node it asked, and Meta's
20
+ own error message.
21
+
22
+
23
+ ## [0.3.2] - 2026-07-26
24
+
25
+ ### Added
26
+
27
+ - **Existing conversations are now imported.** The gem only ever created threads from inbound
28
+ webhooks, and webhooks only announce NEW activity — so a freshly connected account showed an
29
+ empty inbox beside an Instagram account full of conversations, and stayed empty until somebody
30
+ happened to message in. `SyncConversationsJob` pulls every thread and the 20 most recent messages
31
+ in each, which is the whole history Meta exposes to any app. It runs on connect and is safe to
32
+ re-run: threads are located by igsid and messages dedupe on `ig_message_id`.
33
+ - `Client#list_conversations` and `Client#conversation_messages`.
34
+
35
+ ### Note
36
+
37
+ The Conversations API is a **Page-token** operation. An account still holding the OAuth user token
38
+ gets `(#3) Application does not have the capability to make this API call` — verified against a live
39
+ account. `AccountReadinessJob` performs that swap, so it must have run before a sync can succeed.
40
+
41
+
9
42
  ## [0.3.1] - 2026-07-26
10
43
 
11
44
  ### Fixed
@@ -0,0 +1,90 @@
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
+ # The conversations edge lives on the Page for Facebook-Login accounts and
20
+ # on the Instagram user for Instagram-Login ones.
21
+ node = account.page_id.presence || account.ig_user_id
22
+ threads = account.client.list_conversations(node_id: node)
23
+ unless threads.success?
24
+ # A sync that fails must say so. Swallowing this once cost a production
25
+ # afternoon: the job "succeeded" in 600ms and imported nothing.
26
+ logger.error("[instagram_connect] conversation sync failed for " \
27
+ "account=#{account.id} node=#{node}: #{threads.error_message}")
28
+ return
29
+ end
30
+
31
+ Array(threads.data["data"]).each { |thread| sync_thread(account, thread["id"]) }
32
+ end
33
+
34
+ private
35
+
36
+ def sync_thread(account, conversation_id)
37
+ result = account.client.conversation_messages(conversation_id: conversation_id)
38
+ return unless result.success?
39
+
40
+ messages = Array(result.data.dig("messages", "data"))
41
+ # A thread with no readable messages tells us nothing and would show as an
42
+ # empty row in the inbox, so it is skipped rather than created.
43
+ return if messages.empty?
44
+
45
+ conversation = Conversation.locate(account: account, igsid: counterpart(messages, account))
46
+ messages.reverse_each { |message| import(conversation, account, message) }
47
+ end
48
+
49
+ # Meta reports both ends of every message; the thread belongs to whichever
50
+ # participant is not us.
51
+ def counterpart(messages, account)
52
+ messages.each do |message|
53
+ [ message.dig("from", "id"), *Array(message.dig("to", "data")).map { |t| t["id"] } ]
54
+ .compact.each { |id| return id.to_s if id.to_s != account.ig_user_id.to_s }
55
+ end
56
+ account.ig_user_id.to_s
57
+ end
58
+
59
+ def import(conversation, account, payload)
60
+ mid = payload["id"].to_s
61
+ return if mid.blank?
62
+
63
+ message = Message.find_or_initialize_by(conversation_id: conversation.id, ig_message_id: mid)
64
+ return unless message.new_record?
65
+
66
+ outbound = payload.dig("from", "id").to_s == account.ig_user_id.to_s
67
+ message.assign_attributes(
68
+ direction: outbound ? "outbound" : "inbound",
69
+ # Historical rows: an outbound message we are reading back has already
70
+ # been delivered, and an inbound one has already arrived.
71
+ status: outbound ? "sent" : "received",
72
+ body: payload["message"],
73
+ source: "sync",
74
+ sent_at: parse_time(payload["created_time"])
75
+ )
76
+ message.account_id = account.id
77
+ message.save!
78
+ conversation.register_message(message)
79
+ rescue ActiveRecord::RecordNotUnique
80
+ # Raced a webhook for the same message. The webhook's copy is the one that
81
+ # matters; ours would have been identical.
82
+ end
83
+
84
+ def parse_time(value)
85
+ Time.zone.parse(value.to_s)
86
+ rescue ArgumentError, TypeError
87
+ nil
88
+ end
89
+ end
90
+ end
@@ -157,6 +157,25 @@ 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
+ #
165
+ # node_id is the object that OWNS the conversations edge, and on the
166
+ # Facebook-Login path that is the PAGE, not the Instagram user — verified
167
+ # in production, where the Instagram user id answered with a capability
168
+ # error while holding a perfectly good Page token.
169
+ def list_conversations(node_id: @ig_user_id, limit: 50)
170
+ collect("/#{require_ig_user_id(node_id)}/conversations",
171
+ { platform: "instagram", fields: "id,updated_time", limit: limit })
172
+ end
173
+
174
+ def conversation_messages(conversation_id:, limit: 20)
175
+ get("/#{conversation_id}",
176
+ { fields: "messages.limit(#{limit}){id,created_time,from,to,message}" })
177
+ end
178
+
160
179
  # One Page by id. /me/accounts answers for the Pages a token may *enumerate*;
161
180
  # this answers for a Page it may *read*. Those are not the same set — a Page
162
181
  # granted through the Login-for-Business asset picker is readable here while
@@ -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
 
@@ -1,3 +1,3 @@
1
1
  module InstagramConnect
2
- VERSION = "0.3.1"
2
+ VERSION = "0.3.3"
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.1
4
+ version: 0.3.3
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