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