instagram_connect 0.2.0 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +36 -0
  3. data/CHANGELOG.md +89 -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/20260715072548_create_instagram_connect_accounts.rb +2 -2
  25. data/db/migrate/20260715072549_create_instagram_connect_conversations.rb +2 -2
  26. data/db/migrate/20260715072550_create_instagram_connect_messages.rb +3 -3
  27. data/db/migrate/20260715072551_create_instagram_connect_inbound_messages.rb +2 -2
  28. data/db/migrate/20260715072552_create_instagram_connect_comments.rb +3 -3
  29. data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
  30. data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
  31. data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
  32. data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
  33. data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
  34. data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
  35. data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
  36. data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
  37. data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
  38. data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
  39. data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
  40. data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
  41. data/docs/CONFIGURATION.md +107 -0
  42. data/lib/instagram_connect/client.rb +109 -3
  43. data/lib/instagram_connect/configuration.rb +10 -0
  44. data/lib/instagram_connect/engine.rb +1 -0
  45. data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
  46. data/lib/instagram_connect/ingest/envelope.rb +31 -0
  47. data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
  48. data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
  49. data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
  50. data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
  51. data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
  52. data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
  53. data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
  54. data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
  55. data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
  56. data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
  57. data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
  58. data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
  59. data/lib/instagram_connect/ingest/registry.rb +93 -0
  60. data/lib/instagram_connect/ingest/summary.rb +75 -0
  61. data/lib/instagram_connect/ingest.rb +27 -105
  62. data/lib/instagram_connect/media/attaching.rb +57 -0
  63. data/lib/instagram_connect/media/limits.rb +46 -0
  64. data/lib/instagram_connect/rate_limiter.rb +88 -0
  65. data/lib/instagram_connect/result.rb +26 -6
  66. data/lib/instagram_connect/text_splitter.rb +51 -0
  67. data/lib/instagram_connect/usage.rb +68 -0
  68. data/lib/instagram_connect/version.rb +1 -1
  69. data/lib/instagram_connect.rb +23 -0
  70. metadata +60 -2
  71. data/MIT-LICENSE +0 -20
@@ -2,12 +2,21 @@ module InstagramConnect
2
2
  # One DM thread — a connected account talking to one Instagram user (igsid).
3
3
  # Threads are shared across the host's operators; ownership lives on the
4
4
  # account, not per-operator.
5
+ #
6
+ # Every writer here is SQL-side and monotonic. Meta guarantees no ordering
7
+ # across webhook batches, so a late-arriving older event must not roll a
8
+ # thread backwards, and concurrent inbound writes must not lose an unread
9
+ # increment.
5
10
  class Conversation < ApplicationRecord
6
11
  self.table_name = "instagram_connect_conversations"
7
12
 
13
+ FOLDERS = %w[primary general requests].freeze
14
+
8
15
  belongs_to :account, class_name: "InstagramConnect::Account"
9
16
  has_many :messages, class_name: "InstagramConnect::Message",
10
17
  foreign_key: :conversation_id, dependent: :destroy
18
+ has_many :reactions, class_name: "InstagramConnect::MessageReaction",
19
+ foreign_key: :conversation_id, dependent: :destroy
11
20
 
12
21
  validates :igsid, presence: true, uniqueness: { scope: :account_id }
13
22
 
@@ -23,21 +32,100 @@ module InstagramConnect
23
32
 
24
33
  # Denormalizes the thread summary + unread count atomically (SQL-side) so
25
34
  # concurrent inbound writes can't lose an unread increment.
35
+ #
36
+ # Keyed on the message's wire time, not our created_at: a webhook batch
37
+ # delayed by a redeploy would otherwise stamp old messages as the newest
38
+ # and reorder the inbox.
26
39
  def register_message(message)
27
- stamp = message.created_at || Time.current
40
+ stamp = message.sent_at || message.created_at || Time.current
41
+ assignments = [
42
+ "last_message_at = #{monotonic('last_message_at')}",
43
+ "last_message_preview = ?",
44
+ "last_event_at = #{monotonic('last_event_at')}"
45
+ ]
46
+ binds = [ stamp, stamp, message.preview, stamp, stamp ]
47
+
28
48
  if message.inbound?
29
- self.class.where(id: id).update_all([
30
- "last_message_at = ?, last_message_preview = ?, last_inbound_at = ?, " \
31
- "unread_count = unread_count + 1, updated_at = ?",
32
- stamp, message.preview, stamp, Time.current
33
- ])
34
- else
35
- self.class.where(id: id).update_all([
36
- "last_message_at = ?, last_message_preview = ?, updated_at = ?",
37
- stamp, message.preview, Time.current
38
- ])
49
+ assignments << "last_inbound_at = #{monotonic('last_inbound_at')}"
50
+ assignments << "unread_count = unread_count + 1"
51
+ binds += [ stamp, stamp ]
39
52
  end
53
+
54
+ apply(assignments, binds)
55
+ end
56
+
57
+ # A read receipt only ever moves forward. A stale one redelivered after a
58
+ # newer message must not mark the thread read again.
59
+ def mark_read_by_customer(at:, mid: nil)
60
+ apply(
61
+ [
62
+ "customer_last_read_mid = CASE WHEN customer_last_read_at IS NULL " \
63
+ "OR customer_last_read_at < ? THEN ? ELSE customer_last_read_mid END",
64
+ "customer_last_read_at = #{monotonic('customer_last_read_at')}",
65
+ "last_event_at = #{monotonic('last_event_at')}"
66
+ ],
67
+ [ at, mid, at, at, at, at ]
68
+ )
69
+ mark_outbound_seen(at)
70
+ self
71
+ end
72
+
73
+ def register_referral(ref:, at:)
74
+ apply(
75
+ [
76
+ "last_referral_ref = CASE WHEN last_event_at IS NULL OR last_event_at < ? " \
77
+ "THEN ? ELSE last_referral_ref END",
78
+ "last_event_at = #{monotonic('last_event_at')}"
79
+ ],
80
+ [ at, ref, at, at ]
81
+ )
82
+ end
83
+
84
+ def register_thread_control(owner_app_id:, at:)
85
+ apply(
86
+ [
87
+ "thread_owner_app_id = CASE WHEN thread_control_at IS NULL OR thread_control_at < ? " \
88
+ "THEN ? ELSE thread_owner_app_id END",
89
+ "thread_control_at = #{monotonic('thread_control_at')}",
90
+ "last_event_at = #{monotonic('last_event_at')}"
91
+ ],
92
+ [ at, owner_app_id, at, at, at, at ]
93
+ )
94
+ end
95
+
96
+ # Any activity that is not a message — a reaction, an opt-in — still counts
97
+ # as the thread being alive, and still reopens Meta's 24-hour window.
98
+ def register_event(at)
99
+ apply([ "last_event_at = #{monotonic('last_event_at')}" ], [ at, at ])
100
+ end
101
+
102
+ # True when another app (typically the Page Inbox) holds thread control, in
103
+ # which case the Send API rejects our messages.
104
+ def controlled_elsewhere?
105
+ thread_control_at.present? && thread_owner_app_id.present?
106
+ end
107
+
108
+ private
109
+
110
+ # SQLite has no GREATEST, so the monotonic guard is spelled as a CASE. Two
111
+ # binds per use: the comparison value and the value to store.
112
+ def monotonic(column)
113
+ "CASE WHEN #{column} IS NULL OR #{column} < ? THEN ? ELSE #{column} END"
114
+ end
115
+
116
+ def apply(assignments, binds)
117
+ self.class.where(id: id).update_all(
118
+ [ "#{assignments.join(', ')}, updated_at = ?", *binds, Time.current ]
119
+ )
40
120
  reload
41
121
  end
122
+
123
+ # The receipt covers everything the customer had received, so every earlier
124
+ # outbound message is seen too — Meta sends one receipt, not one per bubble.
125
+ def mark_outbound_seen(at)
126
+ messages.where(direction: "outbound", seen_at: nil)
127
+ .where(sent_at: ..at)
128
+ .update_all(seen_at: at, updated_at: Time.current)
129
+ end
42
130
  end
43
131
  end
@@ -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
@@ -1,6 +1,6 @@
1
1
  class CreateInstagramConnectAccounts < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_table :instagram_connect_accounts do |t|
3
+ create_table :instagram_connect_accounts, if_not_exists: true do |t|
4
4
  t.string :auth_path, null: false
5
5
  t.string :ig_user_id, null: false
6
6
  t.string :page_id
@@ -11,6 +11,6 @@ class CreateInstagramConnectAccounts < ActiveRecord::Migration[7.1]
11
11
  t.bigint :connected_by_id
12
12
  t.timestamps
13
13
  end
14
- add_index :instagram_connect_accounts, :ig_user_id, unique: true
14
+ add_index :instagram_connect_accounts, :ig_user_id, unique: true, if_not_exists: true
15
15
  end
16
16
  end
@@ -1,6 +1,6 @@
1
1
  class CreateInstagramConnectConversations < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_table :instagram_connect_conversations do |t|
3
+ create_table :instagram_connect_conversations, if_not_exists: true do |t|
4
4
  t.bigint :account_id, null: false
5
5
  t.string :igsid, null: false
6
6
  t.string :username
@@ -11,6 +11,6 @@ class CreateInstagramConnectConversations < ActiveRecord::Migration[7.1]
11
11
  t.integer :unread_count, default: 0, null: false
12
12
  t.timestamps
13
13
  end
14
- add_index :instagram_connect_conversations, [ :account_id, :igsid ], unique: true
14
+ add_index :instagram_connect_conversations, [ :account_id, :igsid ], unique: true, if_not_exists: true
15
15
  end
16
16
  end
@@ -1,6 +1,6 @@
1
1
  class CreateInstagramConnectMessages < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_table :instagram_connect_messages do |t|
3
+ create_table :instagram_connect_messages, if_not_exists: true do |t|
4
4
  t.bigint :conversation_id, null: false
5
5
  t.string :direction, null: false
6
6
  t.string :status, null: false
@@ -19,7 +19,7 @@ class CreateInstagramConnectMessages < ActiveRecord::Migration[7.1]
19
19
  t.string :failure_reason
20
20
  t.timestamps
21
21
  end
22
- add_index :instagram_connect_messages, :conversation_id
23
- add_index :instagram_connect_messages, :ig_message_id, unique: true
22
+ add_index :instagram_connect_messages, :conversation_id, if_not_exists: true
23
+ add_index :instagram_connect_messages, :ig_message_id, unique: true, if_not_exists: true
24
24
  end
25
25
  end
@@ -1,11 +1,11 @@
1
1
  class CreateInstagramConnectInboundMessages < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_table :instagram_connect_inbound_messages do |t|
3
+ create_table :instagram_connect_inbound_messages, if_not_exists: true do |t|
4
4
  t.string :ig_message_id, null: false
5
5
  t.bigint :account_id
6
6
  t.datetime :processed_at
7
7
  t.timestamps
8
8
  end
9
- add_index :instagram_connect_inbound_messages, :ig_message_id, unique: true
9
+ add_index :instagram_connect_inbound_messages, :ig_message_id, unique: true, if_not_exists: true
10
10
  end
11
11
  end
@@ -1,6 +1,6 @@
1
1
  class CreateInstagramConnectComments < ActiveRecord::Migration[7.1]
2
2
  def change
3
- create_table :instagram_connect_comments do |t|
3
+ create_table :instagram_connect_comments, if_not_exists: true do |t|
4
4
  t.bigint :account_id, null: false
5
5
  t.string :media_id
6
6
  t.string :comment_id, null: false
@@ -11,7 +11,7 @@ class CreateInstagramConnectComments < ActiveRecord::Migration[7.1]
11
11
  t.datetime :replied_at
12
12
  t.timestamps
13
13
  end
14
- add_index :instagram_connect_comments, :comment_id, unique: true
15
- add_index :instagram_connect_comments, :account_id
14
+ add_index :instagram_connect_comments, :comment_id, unique: true, if_not_exists: true
15
+ add_index :instagram_connect_comments, :account_id, if_not_exists: true
16
16
  end
17
17
  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