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
@@ -1,10 +1,18 @@
1
1
  module InstagramConnect
2
- # At-least-once dedupe ledger keyed by Meta's message id. Lets the webhook
3
- # (and any future backfill/poll) run without double-processing a message.
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 given message id is claimed.
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
- scope :chronological, -> { order(:created_at, :id) }
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