instagram_connect 0.2.1 → 0.3.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +36 -0
  3. data/CHANGELOG.md +81 -0
  4. data/Gemfile +4 -0
  5. data/README.md +328 -73
  6. data/app/controllers/instagram_connect/comments_controller.rb +1 -1
  7. data/app/controllers/instagram_connect/posts_controller.rb +1 -1
  8. data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
  9. data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
  10. data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
  11. data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
  12. data/app/jobs/instagram_connect/send_message_job.rb +44 -16
  13. data/app/models/instagram_connect/account.rb +27 -1
  14. data/app/models/instagram_connect/api_budget.rb +22 -0
  15. data/app/models/instagram_connect/conversation.rb +99 -11
  16. data/app/models/instagram_connect/inbound_message.rb +11 -3
  17. data/app/models/instagram_connect/insight_snapshot.rb +43 -0
  18. data/app/models/instagram_connect/media_item.rb +38 -0
  19. data/app/models/instagram_connect/mention.rb +37 -0
  20. data/app/models/instagram_connect/message.rb +34 -2
  21. data/app/models/instagram_connect/message_attachment.rb +53 -0
  22. data/app/models/instagram_connect/message_reaction.rb +33 -0
  23. data/app/models/instagram_connect/webhook_event.rb +67 -0
  24. data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
  25. data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
  26. data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
  27. data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
  28. data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
  29. data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
  30. data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
  31. data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
  32. data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
  33. data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
  34. data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
  35. data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
  36. data/docs/CONFIGURATION.md +107 -0
  37. data/lib/instagram_connect/client.rb +109 -3
  38. data/lib/instagram_connect/configuration.rb +10 -0
  39. data/lib/instagram_connect/engine.rb +1 -0
  40. data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
  41. data/lib/instagram_connect/ingest/envelope.rb +31 -0
  42. data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
  43. data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
  44. data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
  45. data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
  46. data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
  47. data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
  48. data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
  49. data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
  50. data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
  51. data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
  52. data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
  53. data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
  54. data/lib/instagram_connect/ingest/registry.rb +93 -0
  55. data/lib/instagram_connect/ingest/summary.rb +75 -0
  56. data/lib/instagram_connect/ingest.rb +27 -105
  57. data/lib/instagram_connect/media/attaching.rb +57 -0
  58. data/lib/instagram_connect/media/limits.rb +46 -0
  59. data/lib/instagram_connect/rate_limiter.rb +88 -0
  60. data/lib/instagram_connect/result.rb +26 -6
  61. data/lib/instagram_connect/text_splitter.rb +51 -0
  62. data/lib/instagram_connect/usage.rb +68 -0
  63. data/lib/instagram_connect/version.rb +1 -1
  64. data/lib/instagram_connect.rb +23 -0
  65. metadata +60 -2
  66. data/MIT-LICENSE +0 -20
@@ -0,0 +1,148 @@
1
+ require "httparty"
2
+
3
+ module InstagramConnect
4
+ # Closes the gap between "an operator clicked Connect" and "events actually
5
+ # flow", then keeps it closed.
6
+ #
7
+ # Two things stand in that gap and neither has a dashboard equivalent:
8
+ #
9
+ # 1. TOKEN GRADE. The OAuth exchange returns a long-lived *user* token, but
10
+ # every Instagram messaging call and the subscription below are Page
11
+ # token operations. This swaps in the Page token. Page tokens minted from
12
+ # a long-lived user token do not expire, so the expiry is cleared with it.
13
+ # 2. PAGE SUBSCRIPTION. Ticking webhook fields in the app dashboard
14
+ # subscribes the *app*. Meta delivers nothing until the *Page* lists the
15
+ # app in its subscribed_apps, which is a per-Page Graph call.
16
+ #
17
+ # Both steps read before they write, so a scheduled run costs two GETs and
18
+ # changes nothing once an account is healthy. That is the point: a token Meta
19
+ # rotates, or a subscription dropped when an app moves between Development and
20
+ # Live mode, heals itself rather than becoming a console command nobody
21
+ # remembers to run.
22
+ #
23
+ # This lives in the gem rather than in a host because the field list has to
24
+ # track what Ingest can actually parse. Maintained separately, the two drift
25
+ # and the host silently receives events it drops.
26
+ class AccountReadinessJob < ApplicationJob
27
+ queue_as :default
28
+
29
+ HTTP_TIMEOUT = 15
30
+
31
+ def self.subscribed_fields
32
+ Ingest::Registry.subscribable_fields
33
+ end
34
+
35
+ # Cheap negative check so an install with no connected account never
36
+ # enqueues anything.
37
+ def self.enqueue_if_needed
38
+ return :nothing_to_do if InstagramConnect.configuration.app_id.blank?
39
+ return :nothing_to_do unless connectable.exists?
40
+
41
+ perform_later
42
+ :enqueued
43
+ end
44
+
45
+ def self.connectable
46
+ Account.active.where.not(page_id: [ nil, "" ])
47
+ end
48
+
49
+ def perform
50
+ return if InstagramConnect.configuration.app_id.blank?
51
+
52
+ self.class.connectable.find_each do |account|
53
+ # Cleared up front rather than on success, so a step that records a soft
54
+ # failure (no Page token, say) is not immediately wiped by the same pass
55
+ # completing without raising.
56
+ account.update_columns(readiness_error: nil) if account.readiness_error.present?
57
+
58
+ ensure_page_token(account)
59
+ ensure_subscription(account)
60
+ rescue StandardError => e
61
+ # One unhealthy account must not stop the rest, and the next run retries
62
+ # anyway. The reason is kept on the row so a health screen can show it.
63
+ account.update_columns(readiness_error: "#{e.class}: #{e.message}".truncate(255))
64
+ logger&.error("[InstagramConnect] readiness failed for account=#{account.id}: #{e.message}")
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def logger
71
+ InstagramConnect.configuration.logger
72
+ end
73
+
74
+ # A Page token's /me is the Page itself; a user token's /me is the person.
75
+ # That one field is the cheapest way to tell which grade we are holding.
76
+ def ensure_page_token(account)
77
+ return if account.page_access_token.present? && account.page_token_verified_at.present?
78
+
79
+ identity = graph_get(account, "/me", fields: "id")
80
+
81
+ # Already holding a Page token — which is the state a host arrives in if
82
+ # it swapped the token itself before this job existed. /me/accounts cannot
83
+ # be called with a Page token, so the token cannot be re-derived; adopt
84
+ # what is there rather than attempting a recovery that cannot work.
85
+ if identity["id"].to_s == account.page_id.to_s
86
+ account.update!(page_access_token: account.api_token, page_token_verified_at: Time.current)
87
+ return
88
+ end
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 }
92
+
93
+ if page.nil? || page["access_token"].blank?
94
+ account.update!(needs_reconnect: true,
95
+ readiness_error: "no Page token for page=#{account.page_id}")
96
+ return
97
+ end
98
+
99
+ account.update!(page_access_token: page["access_token"], page_token_verified_at: Time.current,
100
+ token_expires_at: nil, needs_reconnect: false)
101
+ end
102
+
103
+ def ensure_subscription(account)
104
+ return if account.page_access_token.blank?
105
+
106
+ wanted = self.class.subscribed_fields
107
+ current = graph_get(account, "/#{account.page_id}/subscribed_apps", fields: "subscribed_fields")
108
+ subscribed = Array(current["data"]).flat_map { |app| Array(app["subscribed_fields"]) }.map(&:to_s)
109
+
110
+ if (wanted - subscribed).empty?
111
+ account.update!(subscribed_fields: subscribed.join(","), subscriptions_synced_at: Time.current)
112
+ return
113
+ end
114
+
115
+ graph_post(account, "/#{account.page_id}/subscribed_apps", subscribed_fields: wanted.join(","))
116
+ account.update!(subscribed_fields: wanted.join(","), subscriptions_synced_at: Time.current)
117
+ end
118
+
119
+ def graph_get(account, path, **query)
120
+ parse(HTTParty.get(url(account, path), headers: bearer(account), query: query,
121
+ timeout: HTTP_TIMEOUT), path)
122
+ end
123
+
124
+ def graph_post(account, path, **body)
125
+ parse(HTTParty.post(url(account, path), headers: bearer(account), body: body,
126
+ timeout: HTTP_TIMEOUT), path)
127
+ end
128
+
129
+ # Host and version come from the account's own strategy, so this can never
130
+ # drift onto a different Graph version than the client's calls.
131
+ def url(account, path)
132
+ config = InstagramConnect.configuration.for_auth_path(account.auth_path)
133
+ "#{Auth.for(config).graph_host}/#{config.graph_version}#{path}"
134
+ end
135
+
136
+ def bearer(account)
137
+ { "Authorization" => "Bearer #{account.api_token}" }
138
+ end
139
+
140
+ def parse(response, path)
141
+ body = response.parsed_response
142
+ body = {} unless body.is_a?(Hash)
143
+ raise "Graph #{path} failed: HTTP #{response.code} #{body.dig('error', 'message')}" unless response.success?
144
+
145
+ body
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,110 @@
1
+ require "httparty"
2
+
3
+ module InstagramConnect
4
+ # Copies one inbound attachment's bytes out of Meta's CDN and into the host's
5
+ # own storage.
6
+ #
7
+ # This runs against a clock nobody controls: Instagram's media URLs expire and
8
+ # there is no endpoint to ask for a fresh one. So a failure here is permanent
9
+ # for that file, which is why the retry ladder ends in a terminal state rather
10
+ # than leaving the row pending — a pending attachment renders as a loading
11
+ # placeholder, and one that never resolves is worse than an honest "this file
12
+ # is no longer available".
13
+ class FetchMediaJob < ApplicationJob
14
+ queue_as :default
15
+
16
+ TIMEOUT = 30
17
+ TRANSPORT_ERRORS = [
18
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT,
19
+ Net::OpenTimeout, Net::ReadTimeout, SocketError, IOError
20
+ ].freeze
21
+
22
+ retry_on(*TRANSPORT_ERRORS, attempts: 3, wait: :polynomially_longer) do |job, error|
23
+ exhausted(job.arguments.first, error)
24
+ end
25
+
26
+ # Retries are spent. Without this the job would land in the dead set and
27
+ # leave the attachment pending for ever — a loading placeholder in the
28
+ # thread that nothing will ever resolve.
29
+ def self.exhausted(attachment_id, error)
30
+ MessageAttachment.find_by(id: attachment_id)&.mark_unavailable!("transport:#{error.class}")
31
+ end
32
+
33
+ def perform(attachment_id)
34
+ attachment = MessageAttachment.find_by(id: attachment_id)
35
+ return if attachment.nil?
36
+ return unless storage_available?
37
+
38
+ # The guard and the write have to be one atomic step: two deliveries of
39
+ # the same webhook would otherwise both see "pending" and both fetch.
40
+ attachment.with_lock do
41
+ next unless attachment.fetchable?
42
+
43
+ fetch_into(attachment)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def fetch_into(attachment)
50
+ response = HTTParty.get(attachment.source_url, timeout: TIMEOUT, follow_redirects: true)
51
+ unless response.success?
52
+ # An expired CDN link is the common case and no amount of retrying
53
+ # fixes it, so this is terminal rather than raised.
54
+ attachment.mark_unavailable!("http_#{response.code}")
55
+ return
56
+ end
57
+
58
+ verdict = Media::Attaching.evaluate(
59
+ body: response.body,
60
+ declared_mime: response.headers["content-type"],
61
+ filename: attachment.filename
62
+ )
63
+
64
+ if verdict.ok?
65
+ store(attachment, response.body, verdict)
66
+ else
67
+ attachment.mark_unavailable!(verdict.error)
68
+ end
69
+ ensure
70
+ refresh_message_media_status(attachment)
71
+ end
72
+
73
+ def store(attachment, body, verdict)
74
+ attachment.file.attach(
75
+ io: StringIO.new(body),
76
+ filename: attachment.filename.presence || default_filename(attachment, verdict.mime),
77
+ content_type: verdict.mime
78
+ )
79
+ attachment.mark_attached!(mime: verdict.mime, size: verdict.size)
80
+ end
81
+
82
+ def default_filename(attachment, mime)
83
+ extension = Rack::Mime::MIME_TYPES.invert[mime] || ""
84
+ "instagram-#{attachment.id}#{extension}"
85
+ end
86
+
87
+ # The message carries the aggregate verdict across its attachments, so the
88
+ # thread can show one state per bubble rather than one per file.
89
+ def refresh_message_media_status(attachment)
90
+ message = attachment.message
91
+ return if message.nil?
92
+
93
+ states = message.attachments.reload.pluck(:state)
94
+ status = if states.include?("pending") then "pending"
95
+ elsif states.any? { |s| s == "attached" } then "attached"
96
+ elsif states.empty? then "none"
97
+ else "unavailable"
98
+ end
99
+
100
+ Message.where(id: message.id).update_all(media_status: status, updated_at: Time.current)
101
+ message.broadcast_refresh
102
+ end
103
+
104
+ # A host without Active Storage still receives messages; it simply keeps the
105
+ # attachment rows as metadata and never fetches bytes.
106
+ def storage_available?
107
+ defined?(ActiveStorage::Blob).present?
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,78 @@
1
+ module InstagramConnect
2
+ # Fills in who people actually are.
3
+ #
4
+ # The columns for this have existed since 0.1 and nothing ever wrote them, so
5
+ # an inbox showed a 17-digit Instagram-scoped id where a handle belongs and an
6
+ # account showed nothing at all. Meta returns profile data on a separate call
7
+ # from the message, so it has to be fetched deliberately.
8
+ #
9
+ # Re-syncing is rate-limited by RESYNC_AFTER rather than by cleverness: a
10
+ # display name changes rarely, and every refetch spends budget that inbound
11
+ # message handling needs more.
12
+ class ProfileSyncJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ RESYNC_AFTER = 7.days
16
+
17
+ def perform(account_id: nil, conversation_id: nil)
18
+ if conversation_id
19
+ sync_conversation(Conversation.find_by(id: conversation_id))
20
+ elsif account_id
21
+ sync_account(Account.find_by(id: account_id))
22
+ else
23
+ Account.active.find_each { |account| sync_account(account) }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def sync_account(account)
30
+ return if account.nil? || fresh?(account.profile_synced_at)
31
+
32
+ result = account.client.account_profile
33
+ return record_failure(account, result) unless result.success?
34
+
35
+ data = result.data
36
+ account.update!(
37
+ username: data["username"].presence || account.username,
38
+ name: data["name"].presence || account.name,
39
+ profile_picture_url: data["profile_picture_url"],
40
+ followers_count: data["followers_count"],
41
+ media_count: data["media_count"],
42
+ profile_synced_at: Time.current
43
+ )
44
+ end
45
+
46
+ def sync_conversation(conversation)
47
+ return if conversation.nil? || fresh?(conversation.profile_synced_at)
48
+
49
+ result = conversation.account.client.profile(igsid: conversation.igsid)
50
+
51
+ # A customer who blocked the business, or deleted their account, cannot be
52
+ # looked up. Stamping the attempt stops the job retrying them on every
53
+ # message they ever sent.
54
+ unless result.success?
55
+ conversation.update_columns(profile_synced_at: Time.current)
56
+ return
57
+ end
58
+
59
+ data = result.data
60
+ conversation.update!(
61
+ username: data["username"].presence || conversation.username,
62
+ display_name: data["name"].presence || conversation.display_name,
63
+ profile_picture_url: data["profile_pic"] || data["profile_picture_url"],
64
+ profile_synced_at: Time.current
65
+ )
66
+ end
67
+
68
+ def fresh?(synced_at)
69
+ synced_at.present? && synced_at > RESYNC_AFTER.ago
70
+ end
71
+
72
+ def record_failure(account, result)
73
+ InstagramConnect.configuration.logger&.warn(
74
+ "[InstagramConnect] profile sync failed for account=#{account.id}: #{result.error_message}"
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,57 @@
1
+ module InstagramConnect
2
+ # Re-runs banked webhook events through the current handlers.
3
+ #
4
+ # This is the payoff for storing raw payloads. Two situations need it, and
5
+ # neither is recoverable any other way, because Meta does not re-deliver:
6
+ #
7
+ # - A field was subscribed before the gem could parse it. Its events banked
8
+ # as `unhandled`; once the handler ships, replay them and the history is
9
+ # complete rather than starting from the deploy.
10
+ # - A handler had a bug. The events are marked `failed` with the error;
11
+ # fix the bug, replay, and no data was lost in between.
12
+ #
13
+ # Replaying is safe to repeat: handlers write through the same upserts and
14
+ # unique indexes the live path uses.
15
+ class ReplayEventsJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ def perform(field: nil, since: nil, limit: 500, config: nil)
19
+ configuration = config || InstagramConnect.configuration
20
+
21
+ scope = WebhookEvent.replayable.chronological.limit(limit)
22
+ scope = scope.for_field(field) if field.present?
23
+ scope = scope.where(created_at: since..) if since.present?
24
+
25
+ scope.find_each do |event|
26
+ replay(event, configuration)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def replay(event, configuration)
33
+ handler_class = Ingest::Registry.handler_for(event.field)
34
+ unless Ingest::Registry.handled?(event.field) && event.account
35
+ # Still nothing that can parse it — leave the row banked rather than
36
+ # burning through the same backlog on every run.
37
+ return
38
+ end
39
+
40
+ envelope = Ingest::Envelope.new(
41
+ account: event.account, object: event.object, field: event.field,
42
+ event_type: event.event_type, event: event.payload, entry_id: event.entry_id,
43
+ occurred_at: event.occurred_at, config: configuration
44
+ )
45
+ handler = handler_class.new(envelope)
46
+ event.increment!(:attempts)
47
+ subject = handler.call
48
+ event.mark_processed!(subject)
49
+ rescue StandardError => e
50
+ event.mark_failed!(e)
51
+ configuration.logger&.error(
52
+ "[InstagramConnect] replay failed for event=#{event.id} field=#{event.field}: " \
53
+ "#{e.class}: #{e.message}"
54
+ )
55
+ end
56
+ end
57
+ end
@@ -1,8 +1,11 @@
1
1
  module InstagramConnect
2
- # Sends one pending outbound Message via the Graph API, exactly once. Claims
3
- # the row (pending -> sending) with an atomic UPDATE so a duplicate enqueue
4
- # can't double-send, then enforces Meta's messaging window auto-applying the
5
- # HUMAN_AGENT tag once the standard 24h window has passed.
2
+ # Sends one pending outbound Message via the Graph API, exactly once.
3
+ #
4
+ # The claim is an atomic UPDATE from pending to sending, so a duplicate
5
+ # enqueue cannot double-send. Everything after it keeps an at-most-once bias:
6
+ # on any doubt the message is marked failed for an operator to retry
7
+ # deliberately, never resent automatically. A duplicate message reaching a
8
+ # customer is worse than a visible failure.
6
9
  class SendMessageJob < ApplicationJob
7
10
  queue_as :default
8
11
 
@@ -12,33 +15,58 @@ module InstagramConnect
12
15
  return unless claimed == 1
13
16
 
14
17
  message = Message.find(message_id)
15
- tag = MessagingWindow.new(last_inbound_at: message.conversation.last_inbound_at).send_tag
18
+ # The claim was callback-free, so a host rendering this thread live needs
19
+ # telling before the send starts rather than after it finishes.
20
+ message.broadcast_refresh
16
21
 
22
+ conversation = message.conversation
23
+
24
+ # Meta rejects a send on a thread another app owns, and the error it
25
+ # returns says nothing useful about why. Refusing here names the reason.
26
+ if conversation.controlled_elsewhere?
27
+ return fail_message(message, "thread_controlled_elsewhere",
28
+ "Another app currently has control of this conversation.")
29
+ end
30
+
31
+ tag = MessagingWindow.new(last_inbound_at: conversation.last_inbound_at).send_tag
17
32
  if tag == :blocked
18
33
  return fail_message(message, "outside_messaging_window",
19
34
  "The reply window has closed; the customer must message again.")
20
35
  end
21
36
 
22
- deliver(message, tag)
37
+ deliver(message, conversation, tag)
23
38
  end
24
39
 
25
40
  private
26
41
 
27
- def deliver(message, tag)
28
- conversation = message.conversation
29
- account = conversation.account
30
- client = Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
31
- result = client.send_text(recipient_id: conversation.igsid, text: message.body.to_s, tag: tag)
32
-
33
- if result.success?
34
- message.update!(status: "sent", ig_message_id: result.id, message_tag: tag)
35
- else
36
- fail_message(message, result.error_code&.to_s, result.error_message)
42
+ def deliver(message, conversation, tag)
43
+ client = conversation.account.client
44
+ parts = TextSplitter.split(message.body.to_s)
45
+
46
+ # Resume rather than restart. A worker killed mid-fan-out has already
47
+ # delivered the earlier parts, and re-sending them repeats text the
48
+ # customer can already see.
49
+ parts.drop(message.delivered_parts_count).each do |part|
50
+ result = client.send_text(recipient_id: conversation.igsid, text: part, tag: tag)
51
+ return fail_message(message, result.error_code&.to_s, result.error_message) unless result.success?
52
+
53
+ message.update!(
54
+ delivered_parts_count: message.delivered_parts_count + 1,
55
+ # Meta returns an id per send. The first is the message's identity and
56
+ # the one its echo will carry back.
57
+ ig_message_id: message.ig_message_id.presence || result.id
58
+ )
37
59
  end
60
+
61
+ message.update!(status: "sent", message_tag: tag)
62
+ message.broadcast_refresh
63
+ message
38
64
  end
39
65
 
40
66
  def fail_message(message, reason, error)
41
67
  message.update!(status: "failed", failure_reason: reason, error_message: error)
68
+ message.broadcast_refresh
69
+ nil
42
70
  end
43
71
  end
44
72
  end
@@ -21,6 +21,29 @@ module InstagramConnect
21
21
  # Encryption configured can opt out via config.encrypt_tokens = false.
22
22
  def self.enable_token_encryption!
23
23
  encrypts :access_token
24
+ encrypts :page_access_token
25
+ end
26
+
27
+ # Every Instagram messaging call and the webhook subscription are Page-token
28
+ # operations. Falling back to the user token keeps a freshly connected
29
+ # account working until the readiness pass swaps in the Page one.
30
+ def api_token
31
+ page_access_token.presence || access_token
32
+ end
33
+
34
+ # The only place a Client should be built. Constructing one directly picks
35
+ # up the *global* auth path, which is wrong for any host with accounts on
36
+ # both paths — and wrong silently, which is worse.
37
+ def client
38
+ InstagramConnect::Client.new(
39
+ access_token: api_token,
40
+ ig_user_id: ig_user_id,
41
+ config: InstagramConnect.configuration.for_auth_path(auth_path)
42
+ )
43
+ end
44
+
45
+ def subscribed_field_list
46
+ subscribed_fields.to_s.split(",").map(&:strip).reject(&:empty?)
24
47
  end
25
48
 
26
49
  def token_expired?
@@ -28,8 +51,11 @@ module InstagramConnect
28
51
  end
29
52
 
30
53
  # Refresh via the account's auth strategy and persist the rotated token.
54
+ # Refreshes via the strategy this row was connected with, not the globally
55
+ # configured one.
31
56
  def refresh_access_token!
32
- data = InstagramConnect::Auth.for(InstagramConnect.configuration).refresh_token(access_token: access_token)
57
+ strategy = InstagramConnect::Auth.for(InstagramConnect.configuration.for_auth_path(auth_path))
58
+ data = strategy.refresh_token(access_token: access_token)
33
59
  update!(access_token: data[:access_token], token_expires_at: data[:expires_at])
34
60
  end
35
61
  end
@@ -0,0 +1,22 @@
1
+ module InstagramConnect
2
+ # One rate-limit bucket for one account, for one window.
3
+ class ApiBudget < ApplicationRecord
4
+ self.table_name = "instagram_connect_api_budgets"
5
+
6
+ belongs_to :account, class_name: "InstagramConnect::Account"
7
+
8
+ validates :bucket, :window_start, :window_seconds, :limit_value, presence: true
9
+
10
+ def window_end
11
+ window_start + window_seconds
12
+ end
13
+
14
+ def blocked?
15
+ blocked_until.present? && blocked_until > Time.current
16
+ end
17
+
18
+ def exhausted?
19
+ used >= limit_value
20
+ end
21
+ end
22
+ end
@@ -2,12 +2,21 @@ module InstagramConnect
2
2
  # One DM thread — a connected account talking to one Instagram user (igsid).
3
3
  # Threads are shared across the host's operators; ownership lives on the
4
4
  # account, not per-operator.
5
+ #
6
+ # Every writer here is SQL-side and monotonic. Meta guarantees no ordering
7
+ # across webhook batches, so a late-arriving older event must not roll a
8
+ # thread backwards, and concurrent inbound writes must not lose an unread
9
+ # increment.
5
10
  class Conversation < ApplicationRecord
6
11
  self.table_name = "instagram_connect_conversations"
7
12
 
13
+ FOLDERS = %w[primary general requests].freeze
14
+
8
15
  belongs_to :account, class_name: "InstagramConnect::Account"
9
16
  has_many :messages, class_name: "InstagramConnect::Message",
10
17
  foreign_key: :conversation_id, dependent: :destroy
18
+ has_many :reactions, class_name: "InstagramConnect::MessageReaction",
19
+ foreign_key: :conversation_id, dependent: :destroy
11
20
 
12
21
  validates :igsid, presence: true, uniqueness: { scope: :account_id }
13
22
 
@@ -23,21 +32,100 @@ module InstagramConnect
23
32
 
24
33
  # Denormalizes the thread summary + unread count atomically (SQL-side) so
25
34
  # concurrent inbound writes can't lose an unread increment.
35
+ #
36
+ # Keyed on the message's wire time, not our created_at: a webhook batch
37
+ # delayed by a redeploy would otherwise stamp old messages as the newest
38
+ # and reorder the inbox.
26
39
  def register_message(message)
27
- stamp = message.created_at || Time.current
40
+ stamp = message.sent_at || message.created_at || Time.current
41
+ assignments = [
42
+ "last_message_at = #{monotonic('last_message_at')}",
43
+ "last_message_preview = ?",
44
+ "last_event_at = #{monotonic('last_event_at')}"
45
+ ]
46
+ binds = [ stamp, stamp, message.preview, stamp, stamp ]
47
+
28
48
  if message.inbound?
29
- self.class.where(id: id).update_all([
30
- "last_message_at = ?, last_message_preview = ?, last_inbound_at = ?, " \
31
- "unread_count = unread_count + 1, updated_at = ?",
32
- stamp, message.preview, stamp, Time.current
33
- ])
34
- else
35
- self.class.where(id: id).update_all([
36
- "last_message_at = ?, last_message_preview = ?, updated_at = ?",
37
- stamp, message.preview, Time.current
38
- ])
49
+ assignments << "last_inbound_at = #{monotonic('last_inbound_at')}"
50
+ assignments << "unread_count = unread_count + 1"
51
+ binds += [ stamp, stamp ]
39
52
  end
53
+
54
+ apply(assignments, binds)
55
+ end
56
+
57
+ # A read receipt only ever moves forward. A stale one redelivered after a
58
+ # newer message must not mark the thread read again.
59
+ def mark_read_by_customer(at:, mid: nil)
60
+ apply(
61
+ [
62
+ "customer_last_read_mid = CASE WHEN customer_last_read_at IS NULL " \
63
+ "OR customer_last_read_at < ? THEN ? ELSE customer_last_read_mid END",
64
+ "customer_last_read_at = #{monotonic('customer_last_read_at')}",
65
+ "last_event_at = #{monotonic('last_event_at')}"
66
+ ],
67
+ [ at, mid, at, at, at, at ]
68
+ )
69
+ mark_outbound_seen(at)
70
+ self
71
+ end
72
+
73
+ def register_referral(ref:, at:)
74
+ apply(
75
+ [
76
+ "last_referral_ref = CASE WHEN last_event_at IS NULL OR last_event_at < ? " \
77
+ "THEN ? ELSE last_referral_ref END",
78
+ "last_event_at = #{monotonic('last_event_at')}"
79
+ ],
80
+ [ at, ref, at, at ]
81
+ )
82
+ end
83
+
84
+ def register_thread_control(owner_app_id:, at:)
85
+ apply(
86
+ [
87
+ "thread_owner_app_id = CASE WHEN thread_control_at IS NULL OR thread_control_at < ? " \
88
+ "THEN ? ELSE thread_owner_app_id END",
89
+ "thread_control_at = #{monotonic('thread_control_at')}",
90
+ "last_event_at = #{monotonic('last_event_at')}"
91
+ ],
92
+ [ at, owner_app_id, at, at, at, at ]
93
+ )
94
+ end
95
+
96
+ # Any activity that is not a message — a reaction, an opt-in — still counts
97
+ # as the thread being alive, and still reopens Meta's 24-hour window.
98
+ def register_event(at)
99
+ apply([ "last_event_at = #{monotonic('last_event_at')}" ], [ at, at ])
100
+ end
101
+
102
+ # True when another app (typically the Page Inbox) holds thread control, in
103
+ # which case the Send API rejects our messages.
104
+ def controlled_elsewhere?
105
+ thread_control_at.present? && thread_owner_app_id.present?
106
+ end
107
+
108
+ private
109
+
110
+ # SQLite has no GREATEST, so the monotonic guard is spelled as a CASE. Two
111
+ # binds per use: the comparison value and the value to store.
112
+ def monotonic(column)
113
+ "CASE WHEN #{column} IS NULL OR #{column} < ? THEN ? ELSE #{column} END"
114
+ end
115
+
116
+ def apply(assignments, binds)
117
+ self.class.where(id: id).update_all(
118
+ [ "#{assignments.join(', ')}, updated_at = ?", *binds, Time.current ]
119
+ )
40
120
  reload
41
121
  end
122
+
123
+ # The receipt covers everything the customer had received, so every earlier
124
+ # outbound message is seen too — Meta sends one receipt, not one per bubble.
125
+ def mark_outbound_seen(at)
126
+ messages.where(direction: "outbound", seen_at: nil)
127
+ .where(sent_at: ..at)
128
+ .update_all(seen_at: at, updated_at: Time.current)
129
+ end
42
130
  end
43
131
  end