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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +36 -0
- data/CHANGELOG.md +81 -0
- data/Gemfile +4 -0
- data/README.md +328 -73
- data/app/controllers/instagram_connect/comments_controller.rb +1 -1
- data/app/controllers/instagram_connect/posts_controller.rb +1 -1
- data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
- data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
- data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
- data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
- data/app/jobs/instagram_connect/send_message_job.rb +44 -16
- data/app/models/instagram_connect/account.rb +27 -1
- data/app/models/instagram_connect/api_budget.rb +22 -0
- data/app/models/instagram_connect/conversation.rb +99 -11
- data/app/models/instagram_connect/inbound_message.rb +11 -3
- data/app/models/instagram_connect/insight_snapshot.rb +43 -0
- data/app/models/instagram_connect/media_item.rb +38 -0
- data/app/models/instagram_connect/mention.rb +37 -0
- data/app/models/instagram_connect/message.rb +34 -2
- data/app/models/instagram_connect/message_attachment.rb +53 -0
- data/app/models/instagram_connect/message_reaction.rb +33 -0
- data/app/models/instagram_connect/webhook_event.rb +67 -0
- data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
- data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
- data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
- data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
- data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
- data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
- data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
- data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
- data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
- data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
- data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
- data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
- data/docs/CONFIGURATION.md +107 -0
- data/lib/instagram_connect/client.rb +109 -3
- data/lib/instagram_connect/configuration.rb +10 -0
- data/lib/instagram_connect/engine.rb +1 -0
- data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
- data/lib/instagram_connect/ingest/envelope.rb +31 -0
- data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
- data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
- data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
- data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
- data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
- data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
- data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
- data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
- data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
- data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
- data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
- data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
- data/lib/instagram_connect/ingest/registry.rb +93 -0
- data/lib/instagram_connect/ingest/summary.rb +75 -0
- data/lib/instagram_connect/ingest.rb +27 -105
- data/lib/instagram_connect/media/attaching.rb +57 -0
- data/lib/instagram_connect/media/limits.rb +46 -0
- data/lib/instagram_connect/rate_limiter.rb +88 -0
- data/lib/instagram_connect/result.rb +26 -6
- data/lib/instagram_connect/text_splitter.rb +51 -0
- data/lib/instagram_connect/usage.rb +68 -0
- data/lib/instagram_connect/version.rb +1 -1
- data/lib/instagram_connect.rb +23 -0
- metadata +60 -2
- 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.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|