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
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
module InstagramConnect
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Dedupe ledger keyed by Meta's message id, scoped to an account.
|
|
3
|
+
#
|
|
4
|
+
# DEPRECATED, removal at 1.0. WebhookEvent is the claim now: keying every
|
|
5
|
+
# event type on one message id is wrong once other fields carry the same id —
|
|
6
|
+
# a messaging_seen receipt names the mid of a message already claimed here, so
|
|
7
|
+
# claiming it would swallow either the receipt or the message. This is kept in
|
|
8
|
+
# step so a host querying it against 0.2.x behaviour still sees every message.
|
|
4
9
|
class InboundMessage < ApplicationRecord
|
|
5
10
|
self.table_name = "instagram_connect_inbound_messages"
|
|
6
11
|
|
|
7
|
-
# Returns true only the first time a
|
|
12
|
+
# Returns true only the first time a message id is claimed for an account.
|
|
13
|
+
# Uniqueness is scoped per account: two connected accounts messaging each
|
|
14
|
+
# other share a mid, and a global claim would drop it for whichever account
|
|
15
|
+
# processed second. A nil account_id therefore no longer dedupes at all.
|
|
8
16
|
def self.claim(ig_message_id:, account_id: nil)
|
|
9
17
|
create!(ig_message_id: ig_message_id, account_id: account_id, processed_at: Time.current)
|
|
10
18
|
true
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One reading of a set of metrics, kept because Meta's own retention is short
|
|
3
|
+
# and, for stories, measured in hours.
|
|
4
|
+
class InsightSnapshot < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_insight_snapshots"
|
|
6
|
+
|
|
7
|
+
SUBJECT_TYPES = %w[account media story].freeze
|
|
8
|
+
SOURCES = %w[api webhook].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :account, class_name: "InstagramConnect::Account"
|
|
11
|
+
|
|
12
|
+
validates :subject_type, inclusion: { in: SUBJECT_TYPES }
|
|
13
|
+
validates :source, inclusion: { in: SOURCES }
|
|
14
|
+
validates :period, :period_start, :captured_at, presence: true
|
|
15
|
+
|
|
16
|
+
scope :for_subject, ->(type, ref) { where(subject_type: type, subject_ref: ref) }
|
|
17
|
+
scope :chronological, -> { order(:period_start, :id) }
|
|
18
|
+
|
|
19
|
+
# Upsert on the natural key. The API poller overwrites its own reading as a
|
|
20
|
+
# story accrues views; the webhook's final reading is a separate row because
|
|
21
|
+
# source is part of the key, so neither can clobber the other.
|
|
22
|
+
def self.record(account:, subject_type:, period:, period_start:, metrics:, captured_at:,
|
|
23
|
+
subject_ref: nil, breakdown: nil, source: "api", empty: false,
|
|
24
|
+
suppressed: false, error_code: nil)
|
|
25
|
+
snapshot = find_or_initialize_by(
|
|
26
|
+
account_id: account.id, subject_type: subject_type, subject_ref: subject_ref,
|
|
27
|
+
period: period, period_start: period_start, breakdown: breakdown, source: source
|
|
28
|
+
)
|
|
29
|
+
snapshot.assign_attributes(
|
|
30
|
+
metrics: metrics, captured_at: captured_at, empty: empty,
|
|
31
|
+
suppressed: suppressed, error_code: error_code
|
|
32
|
+
)
|
|
33
|
+
snapshot.save!
|
|
34
|
+
snapshot
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# nil means Meta did not report the metric, which is not the same as zero —
|
|
38
|
+
# so callers must never reach for a default here.
|
|
39
|
+
def value(metric)
|
|
40
|
+
metrics.is_a?(Hash) ? metrics[metric.to_s] : nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# A post, reel or story on the connected account — "IG Media" in Meta's
|
|
3
|
+
# vocabulary. Named MediaItem because InstagramConnect::Media is already the
|
|
4
|
+
# namespace for inbound file handling (Media::Limits, Media::Attaching); the
|
|
5
|
+
# table keeps Meta's noun.
|
|
6
|
+
class MediaItem < ApplicationRecord
|
|
7
|
+
self.table_name = "instagram_connect_media"
|
|
8
|
+
|
|
9
|
+
PRODUCT_TYPES = %w[FEED STORY REELS AD].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :account, class_name: "InstagramConnect::Account"
|
|
12
|
+
has_many :insight_snapshots, -> { where(subject_type: "media") },
|
|
13
|
+
class_name: "InstagramConnect::InsightSnapshot",
|
|
14
|
+
foreign_key: :subject_ref, primary_key: :ig_media_id,
|
|
15
|
+
dependent: :destroy, inverse_of: false
|
|
16
|
+
|
|
17
|
+
validates :ig_media_id, presence: true, uniqueness: { scope: :account_id }
|
|
18
|
+
|
|
19
|
+
scope :stories, -> { where(media_product_type: "STORY") }
|
|
20
|
+
scope :recent, -> { order(Arel.sql("posted_at IS NULL, posted_at DESC")) }
|
|
21
|
+
|
|
22
|
+
# Upserts by Meta's id. Media arrives from several directions — a webhook
|
|
23
|
+
# about a comment, a story expiring, a sync — and whichever gets there first
|
|
24
|
+
# should create the row without the others having to care.
|
|
25
|
+
def self.record(account:, ig_media_id:, **attributes)
|
|
26
|
+
media = find_or_initialize_by(account_id: account.id, ig_media_id: ig_media_id.to_s)
|
|
27
|
+
media.assign_attributes(attributes.compact)
|
|
28
|
+
media.save!
|
|
29
|
+
media
|
|
30
|
+
rescue ActiveRecord::RecordNotUnique
|
|
31
|
+
find_by!(account_id: account.id, ig_media_id: ig_media_id.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def story?
|
|
35
|
+
media_product_type == "STORY"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# An @mention of the account, in a comment, a caption or a tag.
|
|
3
|
+
#
|
|
4
|
+
# Meta does not store these notifications and will not resend them, so this
|
|
5
|
+
# table is the only record that a mention ever happened.
|
|
6
|
+
class Mention < ApplicationRecord
|
|
7
|
+
self.table_name = "instagram_connect_mentions"
|
|
8
|
+
|
|
9
|
+
KINDS = %w[comment caption tag story].freeze
|
|
10
|
+
|
|
11
|
+
belongs_to :account, class_name: "InstagramConnect::Account"
|
|
12
|
+
belongs_to :message, class_name: "InstagramConnect::Message", optional: true
|
|
13
|
+
|
|
14
|
+
validates :kind, inclusion: { in: KINDS }
|
|
15
|
+
validates :mentioned_at, presence: true
|
|
16
|
+
|
|
17
|
+
scope :unanswered, -> { where(replied_at: nil) }
|
|
18
|
+
scope :recent, -> { order(mentioned_at: :desc) }
|
|
19
|
+
|
|
20
|
+
def self.record(account:, kind:, mentioned_at:, ig_media_id: nil, comment_id: nil, **attributes)
|
|
21
|
+
mention = find_or_initialize_by(
|
|
22
|
+
account_id: account.id, kind: kind,
|
|
23
|
+
ig_media_id: ig_media_id.presence, comment_id: comment_id.presence
|
|
24
|
+
)
|
|
25
|
+
mention.assign_attributes(attributes.compact.merge(mentioned_at: mentioned_at))
|
|
26
|
+
mention.save!
|
|
27
|
+
mention
|
|
28
|
+
rescue ActiveRecord::RecordNotUnique
|
|
29
|
+
find_by!(account_id: account.id, kind: kind,
|
|
30
|
+
ig_media_id: ig_media_id.presence, comment_id: comment_id.presence)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def answered?
|
|
34
|
+
replied_at.present?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -5,12 +5,21 @@ module InstagramConnect
|
|
|
5
5
|
|
|
6
6
|
DIRECTIONS = %w[inbound outbound].freeze
|
|
7
7
|
STATUSES = %w[received pending sending sent failed].freeze
|
|
8
|
-
KINDS = %w[dm story_reply reaction].freeze
|
|
8
|
+
KINDS = %w[dm story_reply story_mention reaction postback share unsupported].freeze
|
|
9
9
|
SOURCES = %w[inbound manual api operator_app].freeze
|
|
10
10
|
MEDIA_STATUSES = %w[none pending downloadable attached unavailable].freeze
|
|
11
11
|
PREVIEW_LIMIT = 140
|
|
12
12
|
|
|
13
13
|
belongs_to :conversation, class_name: "InstagramConnect::Conversation"
|
|
14
|
+
# Denormalized from the conversation so account-scoped queries and stream
|
|
15
|
+
# names do not need a join, and so uniqueness can be scoped per account.
|
|
16
|
+
before_validation :inherit_account_id
|
|
17
|
+
|
|
18
|
+
has_many :attachments, -> { order(:position) },
|
|
19
|
+
class_name: "InstagramConnect::MessageAttachment",
|
|
20
|
+
foreign_key: :message_id, dependent: :destroy
|
|
21
|
+
has_many :reactions, class_name: "InstagramConnect::MessageReaction",
|
|
22
|
+
foreign_key: :message_id, dependent: :nullify
|
|
14
23
|
|
|
15
24
|
validates :direction, inclusion: { in: DIRECTIONS }
|
|
16
25
|
validates :status, inclusion: { in: STATUSES }
|
|
@@ -18,7 +27,24 @@ module InstagramConnect
|
|
|
18
27
|
|
|
19
28
|
scope :inbound, -> { where(direction: "inbound") }
|
|
20
29
|
scope :outbound, -> { where(direction: "outbound") }
|
|
21
|
-
|
|
30
|
+
# Ordered by Meta's clock, falling back to ours for rows written before
|
|
31
|
+
# sent_at existed. A webhook batch delayed by a redeploy would otherwise
|
|
32
|
+
# reorder the thread.
|
|
33
|
+
scope :chronological, -> { order(Arel.sql("COALESCE(sent_at, created_at)"), :id) }
|
|
34
|
+
|
|
35
|
+
# Extension point: a host rendering this record live overrides it to
|
|
36
|
+
# re-broadcast current state after a callback-free write (update_all, which
|
|
37
|
+
# the send and media paths both use to stay atomic). A no-op here keeps the
|
|
38
|
+
# gem free of any Turbo dependency.
|
|
39
|
+
def broadcast_refresh; end
|
|
40
|
+
|
|
41
|
+
def occurred_at
|
|
42
|
+
sent_at || created_at
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def deleted?
|
|
46
|
+
deleted_at.present?
|
|
47
|
+
end
|
|
22
48
|
|
|
23
49
|
def inbound?
|
|
24
50
|
direction == "inbound"
|
|
@@ -33,5 +59,11 @@ module InstagramConnect
|
|
|
33
59
|
return body.to_s.truncate(PREVIEW_LIMIT) if body.present?
|
|
34
60
|
"[#{kind}]"
|
|
35
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def inherit_account_id
|
|
66
|
+
self.account_id ||= conversation&.account_id
|
|
67
|
+
end
|
|
36
68
|
end
|
|
37
69
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One file on a message. Rows are created at ingest with everything already
|
|
3
|
+
# decided except the bytes, which a job fetches separately.
|
|
4
|
+
class MessageAttachment < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_message_attachments"
|
|
6
|
+
|
|
7
|
+
# Instagram gives no second chance at inbound media: the CDN URL expires and
|
|
8
|
+
# there is no endpoint to ask again. So there is no "downloadable" state
|
|
9
|
+
# here as there is for transports that keep the file — an attachment either
|
|
10
|
+
# lands in storage or is permanently unavailable.
|
|
11
|
+
STATES = %w[pending attached unavailable skipped].freeze
|
|
12
|
+
|
|
13
|
+
# Meta's own vocabulary for what the attachment is, which is not the same
|
|
14
|
+
# question as what MIME type the bytes turn out to be.
|
|
15
|
+
KINDS = %w[image video audio file share story_mention ig_reel reel template].freeze
|
|
16
|
+
|
|
17
|
+
belongs_to :message, class_name: "InstagramConnect::Message"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
validates :position, presence: true, uniqueness: { scope: :message_id }
|
|
21
|
+
validates :kind, presence: true
|
|
22
|
+
validates :state, inclusion: { in: STATES }
|
|
23
|
+
|
|
24
|
+
# Called from the engine's to_prepare when Active Storage is present, the
|
|
25
|
+
# same shape as Account.enable_token_encryption!. The gem does not depend on
|
|
26
|
+
# Active Storage, so a host without it still boots and simply keeps the
|
|
27
|
+
# attachment rows as metadata without ever fetching bytes.
|
|
28
|
+
def self.enable_file_attachment!
|
|
29
|
+
has_one_attached :file
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
scope :pending, -> { where(state: "pending") }
|
|
33
|
+
scope :ordered, -> { order(:position) }
|
|
34
|
+
|
|
35
|
+
def fetchable?
|
|
36
|
+
state == "pending" && source_url.present?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attached?
|
|
40
|
+
state == "attached"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mark_attached!(mime:, size:, filename: nil)
|
|
44
|
+
update!(state: "attached", mime: mime, size: size, filename: filename, error: nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Terminal. Nothing retries after this, because retrying a URL Instagram has
|
|
48
|
+
# already expired only burns the rate budget.
|
|
49
|
+
def mark_unavailable!(reason)
|
|
50
|
+
update!(state: "unavailable", error: reason.to_s.truncate(255))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One reaction event on a message. Append-only — see the migration for why
|
|
3
|
+
# current state is a read rather than a column.
|
|
4
|
+
class MessageReaction < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_message_reactions"
|
|
6
|
+
|
|
7
|
+
ACTIONS = %w[react unreact].freeze
|
|
8
|
+
ACTORS = %w[customer business].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :conversation, class_name: "InstagramConnect::Conversation"
|
|
11
|
+
belongs_to :message, class_name: "InstagramConnect::Message", optional: true
|
|
12
|
+
|
|
13
|
+
validates :ig_message_id, :igsid, :reacted_at, presence: true
|
|
14
|
+
validates :action, inclusion: { in: ACTIONS }
|
|
15
|
+
validates :actor, inclusion: { in: ACTORS }
|
|
16
|
+
|
|
17
|
+
scope :chronological, -> { order(:reacted_at, :id) }
|
|
18
|
+
|
|
19
|
+
# The reaction currently showing on a message, or nil if the last event was
|
|
20
|
+
# an unreact. Reading MAX(reacted_at) rather than trusting arrival order is
|
|
21
|
+
# what makes out-of-order delivery harmless.
|
|
22
|
+
def self.current_for(ig_message_id)
|
|
23
|
+
latest = where(ig_message_id: ig_message_id).chronological.last
|
|
24
|
+
return nil if latest.nil? || latest.action == "unreact"
|
|
25
|
+
|
|
26
|
+
latest
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def react?
|
|
30
|
+
action == "react"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One row per webhook event Meta delivered, stored verbatim before anything
|
|
3
|
+
# interprets it. See the migration for why this exists rather than a counter.
|
|
4
|
+
class WebhookEvent < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_webhook_events"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending processed unhandled failed].freeze
|
|
8
|
+
|
|
9
|
+
belongs_to :account, class_name: "InstagramConnect::Account", optional: true
|
|
10
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
11
|
+
|
|
12
|
+
validates :field, :event_type, :dedupe_key, presence: true
|
|
13
|
+
validates :status, inclusion: { in: STATUSES }
|
|
14
|
+
|
|
15
|
+
scope :pending, -> { where(status: "pending") }
|
|
16
|
+
scope :replayable, -> { where(status: %w[pending unhandled failed]) }
|
|
17
|
+
scope :for_field, ->(field) { where(field: field) }
|
|
18
|
+
scope :chronological, -> { order(:occurred_at, :id) }
|
|
19
|
+
|
|
20
|
+
# Claims an event for processing, or returns nil if this exact event was
|
|
21
|
+
# already delivered. Meta retries webhooks it considers unacknowledged, so
|
|
22
|
+
# duplicates are routine rather than exceptional — the unique index on
|
|
23
|
+
# [field, dedupe_key] is what makes the claim atomic under concurrency.
|
|
24
|
+
def self.claim(field:, dedupe_key:, event_type:, payload:, account: nil,
|
|
25
|
+
object: nil, entry_id: nil, occurred_at: nil, handler: nil)
|
|
26
|
+
create!(
|
|
27
|
+
account: account,
|
|
28
|
+
object: object,
|
|
29
|
+
field: field,
|
|
30
|
+
event_type: event_type,
|
|
31
|
+
dedupe_key: dedupe_key,
|
|
32
|
+
entry_id: entry_id,
|
|
33
|
+
occurred_at: occurred_at,
|
|
34
|
+
received_at: Time.current,
|
|
35
|
+
handler: handler,
|
|
36
|
+
payload: payload,
|
|
37
|
+
status: "pending"
|
|
38
|
+
)
|
|
39
|
+
rescue ActiveRecord::RecordNotUnique
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mark_processed!(subject = nil)
|
|
44
|
+
update!(status: "processed", processed_at: Time.current, subject: subject)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Recorded, not dropped: a field the gem does not parse yet is still worth
|
|
48
|
+
# keeping, because replaying it later is the only way to recover events Meta
|
|
49
|
+
# will never send again.
|
|
50
|
+
def mark_unhandled!
|
|
51
|
+
update!(status: "unhandled", processed_at: Time.current)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_failed!(error)
|
|
55
|
+
update!(
|
|
56
|
+
status: "failed",
|
|
57
|
+
processed_at: Time.current,
|
|
58
|
+
error_class: error.class.name,
|
|
59
|
+
error_message: error.message.to_s.truncate(1000)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def processed?
|
|
64
|
+
status == "processed"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
class CreateInstagramConnectWebhookEvents < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Every webhook event Meta delivers, stored verbatim before anything tries to
|
|
3
|
+
# interpret it.
|
|
4
|
+
#
|
|
5
|
+
# Meta does not store `mentions` or `story_insights` notifications and never
|
|
6
|
+
# re-delivers any event, so a parse bug against a live payload is permanent
|
|
7
|
+
# data loss. With this table it is a re-runnable row instead. It also lets an
|
|
8
|
+
# operator subscribe a field at Meta before its handler exists — the events
|
|
9
|
+
# bank as "unhandled" and ReplayEventsJob walks them once the handler ships.
|
|
10
|
+
def change
|
|
11
|
+
create_table :instagram_connect_webhook_events, if_not_exists: true do |t|
|
|
12
|
+
# Nullable: an entry whose id matches no connected account is recorded
|
|
13
|
+
# rather than dropped. That is how you discover a second Page was
|
|
14
|
+
# connected to the app, instead of losing a week of DMs to a counter.
|
|
15
|
+
t.bigint :account_id
|
|
16
|
+
t.string :object
|
|
17
|
+
t.string :field, null: false
|
|
18
|
+
t.string :event_type, null: false
|
|
19
|
+
t.string :dedupe_key, null: false
|
|
20
|
+
t.string :entry_id
|
|
21
|
+
t.datetime :occurred_at
|
|
22
|
+
t.datetime :received_at, null: false
|
|
23
|
+
t.datetime :processed_at
|
|
24
|
+
t.string :status, null: false, default: "pending"
|
|
25
|
+
t.string :handler
|
|
26
|
+
t.string :error_class
|
|
27
|
+
t.text :error_message
|
|
28
|
+
t.integer :attempts, null: false, default: 0
|
|
29
|
+
t.string :subject_type
|
|
30
|
+
t.bigint :subject_id
|
|
31
|
+
t.column :payload, json_type, null: false
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
add_index :instagram_connect_webhook_events, [ :field, :dedupe_key ],
|
|
36
|
+
unique: true, if_not_exists: true
|
|
37
|
+
add_index :instagram_connect_webhook_events, [ :account_id, :field, :occurred_at ],
|
|
38
|
+
name: "index_instagram_connect_webhook_events_on_account_field_time",
|
|
39
|
+
if_not_exists: true
|
|
40
|
+
add_index :instagram_connect_webhook_events, [ :status, :created_at ], if_not_exists: true
|
|
41
|
+
add_index :instagram_connect_webhook_events, [ :subject_type, :subject_id ], if_not_exists: true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The gem ships into whichever database the host runs. jsonb is the right
|
|
47
|
+
# column on Postgres and does not exist on SQLite, so pick per adapter rather
|
|
48
|
+
# than forcing every adopter onto one.
|
|
49
|
+
def json_type
|
|
50
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
class AddMessagingEventFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Columns for the messaging events beyond plain DMs — reactions, read
|
|
3
|
+
# receipts, referrals, thread handover, quick replies, story replies — plus
|
|
4
|
+
# the wire clock all of their ordering depends on.
|
|
5
|
+
#
|
|
6
|
+
# Written as individual add_column calls rather than a bulk change_table
|
|
7
|
+
# because only add_column honours if_not_exists, and every migration this gem
|
|
8
|
+
# ships has to survive being re-run against a database that already has the
|
|
9
|
+
# columns (0.2.1 shipped to fix exactly that).
|
|
10
|
+
MESSAGE_COLUMNS = {
|
|
11
|
+
# Meta's own timestamp. Ordering used our created_at, so a webhook batch
|
|
12
|
+
# delayed by a redeploy silently reordered a thread. Backfilled below, and
|
|
13
|
+
# left nullable on purpose: promoting it to NOT NULL takes a lock this gem
|
|
14
|
+
# cannot know the cost of in an adopter's database.
|
|
15
|
+
sent_at: { type: :datetime },
|
|
16
|
+
seen_at: { type: :datetime },
|
|
17
|
+
# The customer unsent the message. The row is never destroyed — the thread
|
|
18
|
+
# should read "message unsent" rather than lose its shape. Erasing it is a
|
|
19
|
+
# host policy decision, not a gem default.
|
|
20
|
+
deleted_at: { type: :datetime },
|
|
21
|
+
unsupported: { type: :boolean, default: false, null: false },
|
|
22
|
+
reply_to_mid: { type: :string },
|
|
23
|
+
quick_reply_payload: { type: :string },
|
|
24
|
+
postback_payload: { type: :string },
|
|
25
|
+
postback_title: { type: :string },
|
|
26
|
+
referral_ref: { type: :string },
|
|
27
|
+
referral_source: { type: :string },
|
|
28
|
+
referral_type: { type: :string },
|
|
29
|
+
referral_product_id: { type: :string },
|
|
30
|
+
story_id: { type: :string },
|
|
31
|
+
story_url: { type: :text },
|
|
32
|
+
# The wire attachment type (image, video, story_mention, ig_reel...),
|
|
33
|
+
# orthogonal to the MIME type of the bytes behind it.
|
|
34
|
+
media_kind: { type: :string },
|
|
35
|
+
# Projections of the reaction log, so a bubble renders without a join.
|
|
36
|
+
reactions_count: { type: :integer, default: 0, null: false },
|
|
37
|
+
last_reaction: { type: :string }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
CONVERSATION_COLUMNS = {
|
|
41
|
+
# Meta splits threads into primary/general/requests. A requests thread idle
|
|
42
|
+
# for 30 days stops being returned by the Conversations API at all, so the
|
|
43
|
+
# folder is how the UI explains a thread it can never re-fetch.
|
|
44
|
+
folder: { type: :string },
|
|
45
|
+
customer_last_read_at: { type: :datetime },
|
|
46
|
+
customer_last_read_mid: { type: :string },
|
|
47
|
+
# Which app currently owns the thread. If the Page Inbox holds control, our
|
|
48
|
+
# Send API call fails — a send has to read this rather than discover it as
|
|
49
|
+
# an opaque API error.
|
|
50
|
+
thread_owner_app_id: { type: :string },
|
|
51
|
+
thread_control_at: { type: :datetime },
|
|
52
|
+
last_referral_ref: { type: :string },
|
|
53
|
+
# Any event at all, including ones that are not messages. Distinct from
|
|
54
|
+
# last_message_at so a quiet-but-active thread still sorts sensibly.
|
|
55
|
+
last_event_at: { type: :datetime }
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
def change
|
|
59
|
+
MESSAGE_COLUMNS.each do |name, options|
|
|
60
|
+
add_column :instagram_connect_messages, name, options[:type],
|
|
61
|
+
**options.except(:type), if_not_exists: true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
CONVERSATION_COLUMNS.each do |name, options|
|
|
65
|
+
add_column :instagram_connect_conversations, name, options[:type],
|
|
66
|
+
**options.except(:type), if_not_exists: true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
add_index :instagram_connect_messages, :sent_at, if_not_exists: true
|
|
70
|
+
add_index :instagram_connect_messages, :reply_to_mid, if_not_exists: true
|
|
71
|
+
|
|
72
|
+
reversible do |dir|
|
|
73
|
+
dir.up { backfill_sent_at }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Existing rows predate the column and every ordering now reads it. Rather
|
|
80
|
+
# than leave them NULL and make every query COALESCE, seed them with the only
|
|
81
|
+
# timestamp we have.
|
|
82
|
+
def backfill_sent_at
|
|
83
|
+
execute(<<~SQL.squish)
|
|
84
|
+
UPDATE instagram_connect_messages
|
|
85
|
+
SET sent_at = created_at
|
|
86
|
+
WHERE sent_at IS NULL
|
|
87
|
+
SQL
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInstagramConnectMessageReactions < ActiveRecord::Migration[7.1]
|
|
2
|
+
# An append-only log of reactions, not a current-state table.
|
|
3
|
+
#
|
|
4
|
+
# Meta guarantees no ordering across webhook batches, so react → unreact →
|
|
5
|
+
# react can arrive in any sequence. Storing each event and reading the latest
|
|
6
|
+
# by reacted_at self-corrects; storing "the" reaction on the message would
|
|
7
|
+
# let a late unreact clear a reaction the customer has since re-added.
|
|
8
|
+
def change
|
|
9
|
+
create_table :instagram_connect_message_reactions, if_not_exists: true do |t|
|
|
10
|
+
t.bigint :conversation_id, null: false
|
|
11
|
+
# Nullable: a reaction can arrive before the message it belongs to, and
|
|
12
|
+
# can point at a message that predates this install entirely.
|
|
13
|
+
t.bigint :message_id
|
|
14
|
+
t.string :ig_message_id, null: false
|
|
15
|
+
t.string :igsid, null: false
|
|
16
|
+
t.string :actor, null: false
|
|
17
|
+
t.string :action, null: false
|
|
18
|
+
t.string :reaction
|
|
19
|
+
t.string :emoji
|
|
20
|
+
t.datetime :reacted_at, null: false
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :instagram_connect_message_reactions, [ :conversation_id, :reacted_at ],
|
|
25
|
+
name: "index_instagram_connect_reactions_on_conversation_and_time",
|
|
26
|
+
if_not_exists: true
|
|
27
|
+
add_index :instagram_connect_message_reactions, :ig_message_id, if_not_exists: true
|
|
28
|
+
add_index :instagram_connect_message_reactions, :message_id, if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateInstagramConnectMessageAttachments < ActiveRecord::Migration[7.1]
|
|
2
|
+
# One row per file on a message, rather than the single set of media_* columns
|
|
3
|
+
# the messages table already carries.
|
|
4
|
+
#
|
|
5
|
+
# A DM can hold up to ten images, and each one fetches independently: one job
|
|
6
|
+
# per attachment means a ten-image message does not serialise behind the
|
|
7
|
+
# slowest file, and a retry ladder isolates to the file that actually failed.
|
|
8
|
+
def change
|
|
9
|
+
create_table :instagram_connect_message_attachments, if_not_exists: true do |t|
|
|
10
|
+
t.bigint :message_id, null: false
|
|
11
|
+
t.integer :position, null: false
|
|
12
|
+
t.string :kind, null: false
|
|
13
|
+
# Meta's CDN URL. Short-lived and not re-requestable, which is why the
|
|
14
|
+
# bytes are copied into the host's own storage rather than linked.
|
|
15
|
+
t.text :source_url
|
|
16
|
+
t.string :state, null: false, default: "pending"
|
|
17
|
+
t.string :mime
|
|
18
|
+
t.string :filename
|
|
19
|
+
t.bigint :size
|
|
20
|
+
t.string :error
|
|
21
|
+
# A reusable id from Meta's Attachment Upload API, so a canned reply image
|
|
22
|
+
# is uploaded once and sent by reference thereafter.
|
|
23
|
+
t.string :ig_attachment_id
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :instagram_connect_message_attachments, [ :message_id, :position ],
|
|
28
|
+
unique: true, if_not_exists: true
|
|
29
|
+
add_index :instagram_connect_message_attachments, [ :state, :created_at ],
|
|
30
|
+
name: "index_instagram_connect_attachments_on_state_and_time",
|
|
31
|
+
if_not_exists: true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class AddOutboundSendFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Individual add_column calls rather than a bulk change_table: only add_column
|
|
3
|
+
# honours if_not_exists, and every migration this gem ships has to survive
|
|
4
|
+
# being re-run against a database that already has the columns.
|
|
5
|
+
COLUMNS = {
|
|
6
|
+
# A message over Meta's 1000-byte ceiling goes out as several sends. This
|
|
7
|
+
# is the resume cursor, so a worker killed mid-fan-out picks up where it
|
|
8
|
+
# stopped instead of repeating parts the customer already received.
|
|
9
|
+
delivered_parts_count: { type: :integer, default: 0, null: false },
|
|
10
|
+
# The reusable id Meta returns from the Attachment Upload API, so a canned
|
|
11
|
+
# reply image is uploaded once and sent by reference afterwards.
|
|
12
|
+
attachment_upload_id: { type: :string }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def change
|
|
16
|
+
COLUMNS.each do |name, options|
|
|
17
|
+
add_column :instagram_connect_messages, name, options[:type],
|
|
18
|
+
**options.except(:type), if_not_exists: true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Composer idempotency: a double-submit carries the same token and the
|
|
22
|
+
# unique index turns the second one into a no-op rather than a second
|
|
23
|
+
# bubble the customer sees twice.
|
|
24
|
+
add_column :instagram_connect_messages, :client_token, :string, if_not_exists: true
|
|
25
|
+
add_index :instagram_connect_messages, :client_token, unique: true, if_not_exists: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInstagramConnectMedia < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Our own record of the account's posts, reels and stories.
|
|
3
|
+
#
|
|
4
|
+
# It exists because insights and mentions both need something to hang off, and
|
|
5
|
+
# because a story is gone from the API 24 hours after it is posted — the row
|
|
6
|
+
# has to outlive the thing it describes.
|
|
7
|
+
def change
|
|
8
|
+
create_table :instagram_connect_media, if_not_exists: true do |t|
|
|
9
|
+
t.bigint :account_id, null: false
|
|
10
|
+
t.string :ig_media_id, null: false
|
|
11
|
+
t.string :media_type
|
|
12
|
+
# FEED | STORY | REELS | AD. Distinct from media_type, and the field that
|
|
13
|
+
# decides which insight metrics Meta will even accept.
|
|
14
|
+
t.string :media_product_type
|
|
15
|
+
t.text :caption
|
|
16
|
+
t.text :permalink
|
|
17
|
+
t.text :media_url
|
|
18
|
+
t.text :thumbnail_url
|
|
19
|
+
t.datetime :posted_at
|
|
20
|
+
t.integer :comments_count
|
|
21
|
+
t.integer :like_count
|
|
22
|
+
t.datetime :synced_at
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_index :instagram_connect_media, [ :account_id, :ig_media_id ],
|
|
27
|
+
unique: true, if_not_exists: true
|
|
28
|
+
add_index :instagram_connect_media, [ :account_id, :posted_at ], if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInstagramConnectMentions < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Every @mention of the account — in a comment, in a caption, or as a tag.
|
|
3
|
+
#
|
|
4
|
+
# This is a table rather than a query because **Meta does not store mention
|
|
5
|
+
# notifications**. There is no endpoint to ask "what did I miss": the webhook
|
|
6
|
+
# is delivered once and if it is not written down at that moment it is gone.
|
|
7
|
+
# For a travel brand these are the highest-value events on the platform —
|
|
8
|
+
# customers posting photos of a trip we sold them.
|
|
9
|
+
def change
|
|
10
|
+
create_table :instagram_connect_mentions, if_not_exists: true do |t|
|
|
11
|
+
t.bigint :account_id, null: false
|
|
12
|
+
t.string :kind, null: false
|
|
13
|
+
t.string :ig_media_id
|
|
14
|
+
t.string :comment_id
|
|
15
|
+
t.bigint :message_id
|
|
16
|
+
t.string :from_username
|
|
17
|
+
t.text :text
|
|
18
|
+
t.text :permalink
|
|
19
|
+
t.datetime :mentioned_at, null: false
|
|
20
|
+
t.datetime :replied_at
|
|
21
|
+
t.string :reply_comment_id
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
add_index :instagram_connect_mentions, [ :account_id, :kind, :ig_media_id, :comment_id ],
|
|
26
|
+
unique: true, name: "index_instagram_connect_mentions_unique", if_not_exists: true
|
|
27
|
+
add_index :instagram_connect_mentions, [ :account_id, :mentioned_at ],
|
|
28
|
+
name: "index_instagram_connect_mentions_on_account_and_time", if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|