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
@@ -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
@@ -0,0 +1,45 @@
1
+ class CreateInstagramConnectInsightSnapshots < ActiveRecord::Migration[7.1]
2
+ # Metric readings, kept because Meta does not keep them for long: story
3
+ # metrics are retrievable for 24 hours, account metrics for 90 days, media
4
+ # metrics for two years. A dashboard reading straight from the API can only
5
+ # ever show that window.
6
+ #
7
+ # Story insights are the acute case. The `story_insights` webhook fires when a
8
+ # story expires and carries its final metrics — that delivery is the only
9
+ # chance to record them, so its handler writes here synchronously rather than
10
+ # deferring to a job that might not run in time.
11
+ def change
12
+ create_table :instagram_connect_insight_snapshots, if_not_exists: true do |t|
13
+ t.bigint :account_id, null: false
14
+ t.string :subject_type, null: false
15
+ t.string :subject_ref
16
+ t.string :period, null: false
17
+ t.date :period_start, null: false
18
+ t.string :breakdown
19
+ # webhook | api. Part of the uniqueness key so the poller overwrites its
20
+ # own readings all day without ever colliding with the webhook's final
21
+ # one, and a dropped webhook still leaves the last mid-life reading.
22
+ t.string :source, null: false, default: "api"
23
+ t.column :metrics, json_type, null: false
24
+ # A metric Meta declines to report is absent from metrics rather than
25
+ # zero — these flags are how a UI tells "not reported" from "really zero".
26
+ t.boolean :empty, null: false, default: false
27
+ t.boolean :suppressed, null: false, default: false
28
+ t.integer :error_code
29
+ t.datetime :captured_at, null: false
30
+ t.timestamps
31
+ end
32
+
33
+ add_index :instagram_connect_insight_snapshots,
34
+ [ :account_id, :subject_type, :subject_ref, :period, :period_start, :breakdown, :source ],
35
+ unique: true, name: "index_instagram_connect_snapshots_unique", if_not_exists: true
36
+ add_index :instagram_connect_insight_snapshots, [ :account_id, :captured_at ],
37
+ name: "index_instagram_connect_snapshots_on_account_and_time", if_not_exists: true
38
+ end
39
+
40
+ private
41
+
42
+ def json_type
43
+ connection.adapter_name.match?(/postg/i) ? :jsonb : :json
44
+ end
45
+ end
@@ -0,0 +1,29 @@
1
+ class AddCommentModerationFields < ActiveRecord::Migration[7.1]
2
+ COLUMNS = {
3
+ # The commenter's Instagram-scoped id. Needed to send them a private reply,
4
+ # which the username alone cannot address.
5
+ from_ig_id: { type: :string },
6
+ commented_at: { type: :datetime },
7
+ like_count: { type: :integer },
8
+ # From the live_comments field. A private reply to a live comment is valid
9
+ # only while the broadcast is running, so this is what stops the UI offering
10
+ # a button that is already guaranteed to fail.
11
+ is_live: { type: :boolean, default: false, null: false },
12
+ deleted_at: { type: :datetime },
13
+ # Distinct from replied_at, which is a public reply. The private reply is a
14
+ # separate, spendable resource: one per commenter, expiring 7 days after the
15
+ # comment.
16
+ private_replied_at: { type: :datetime },
17
+ private_reply_message_id: { type: :bigint }
18
+ }.freeze
19
+
20
+ def change
21
+ COLUMNS.each do |name, options|
22
+ add_column :instagram_connect_comments, name, options[:type],
23
+ **options.except(:type), if_not_exists: true
24
+ end
25
+
26
+ add_index :instagram_connect_comments, [ :account_id, :commented_at ],
27
+ name: "index_instagram_connect_comments_on_account_and_time", if_not_exists: true
28
+ end
29
+ end
@@ -0,0 +1,76 @@
1
+ class AddPerAccountTokenFields < ActiveRecord::Migration[7.1]
2
+ ACCOUNT_COLUMNS = {
3
+ # The OAuth exchange returns a *user* token, but every Instagram messaging
4
+ # endpoint and the webhook subscription are Page-token operations. Keeping
5
+ # them in separate columns is what lets the readiness pass tell which grade
6
+ # it is holding instead of guessing.
7
+ page_access_token: { type: :text },
8
+ page_token_verified_at: { type: :datetime },
9
+ # Set when a Page call comes back with an auth error. There is no silent
10
+ # recovery from this — the operator has to reconnect — so it surfaces as a
11
+ # flag rather than as a retry that can never succeed.
12
+ needs_reconnect: { type: :boolean, default: false, null: false },
13
+ readiness_error: { type: :string },
14
+ # The fields the Page is actually subscribed to, as last read from Meta.
15
+ # Lets the readiness pass skip a POST it does not need, and lets a health
16
+ # screen show what is really live rather than what we hoped.
17
+ subscribed_fields: { type: :text },
18
+ subscriptions_synced_at: { type: :datetime },
19
+ name: { type: :string },
20
+ profile_picture_url: { type: :text },
21
+ profile_synced_at: { type: :datetime }
22
+ }.freeze
23
+
24
+ def change
25
+ ACCOUNT_COLUMNS.each do |name, options|
26
+ add_column :instagram_connect_accounts, name, options[:type],
27
+ **options.except(:type), if_not_exists: true
28
+ end
29
+
30
+ add_column :instagram_connect_messages, :account_id, :bigint, if_not_exists: true
31
+
32
+ reversible do |dir|
33
+ dir.up { backfill_message_accounts }
34
+ end
35
+
36
+ # Uniqueness has to be per account, not global. With two connected accounts
37
+ # that message each other, the echo's mid is claimed by whichever processes
38
+ # first and permanently dropped for the other — a live correctness bug, not
39
+ # hygiene. Same reasoning for comments and the inbound ledger.
40
+ add_index :instagram_connect_messages, [ :account_id, :ig_message_id ],
41
+ unique: true, name: "index_instagram_connect_messages_on_account_and_mid",
42
+ if_not_exists: true
43
+ remove_index :instagram_connect_messages, column: :ig_message_id, if_exists: true
44
+
45
+ add_index :instagram_connect_comments, [ :account_id, :comment_id ],
46
+ unique: true, name: "index_instagram_connect_comments_on_account_and_comment",
47
+ if_not_exists: true
48
+ remove_index :instagram_connect_comments, column: :comment_id, if_exists: true
49
+
50
+ add_index :instagram_connect_inbound_messages, [ :account_id, :ig_message_id ],
51
+ unique: true, name: "index_instagram_connect_inbound_on_account_and_mid",
52
+ if_not_exists: true
53
+ remove_index :instagram_connect_inbound_messages, column: :ig_message_id, if_exists: true
54
+ end
55
+
56
+ private
57
+
58
+ # Denormalized from the conversation so account-scoped inbox queries and
59
+ # stream names do not need a join. Filled inline because the new unique index
60
+ # is only meaningful once it is populated — a NULL account_id would let the
61
+ # very duplicates this index exists to prevent slip through.
62
+ #
63
+ # A correlated subquery rather than UPDATE...FROM, which is Postgres-only.
64
+ # An adopter with millions of messages should run this chunked before
65
+ # upgrading; at the scale this gem is written for it is a single indexed pass.
66
+ def backfill_message_accounts
67
+ execute(<<~SQL.squish)
68
+ UPDATE instagram_connect_messages
69
+ SET account_id = (
70
+ SELECT account_id FROM instagram_connect_conversations
71
+ WHERE instagram_connect_conversations.id = instagram_connect_messages.conversation_id
72
+ )
73
+ WHERE account_id IS NULL
74
+ SQL
75
+ end
76
+ end
@@ -0,0 +1,29 @@
1
+ class CreateInstagramConnectApiBudgets < ActiveRecord::Migration[7.1]
2
+ # A shared, durable counter per account per rate-limit bucket.
3
+ #
4
+ # Meta's limits are per Instagram account, not per process, so a counter held
5
+ # in memory is wrong the moment there is more than one worker — and resets to
6
+ # zero on every deploy, which is exactly when a backlog is most likely to
7
+ # burst through the ceiling. A row with a unique key on the window makes the
8
+ # increment atomic across processes in one statement.
9
+ def change
10
+ create_table :instagram_connect_api_budgets, if_not_exists: true do |t|
11
+ t.bigint :account_id, null: false
12
+ t.string :bucket, null: false
13
+ t.datetime :window_start, null: false
14
+ t.integer :window_seconds, null: false
15
+ t.integer :used, null: false, default: 0
16
+ t.integer :limit_value, null: false
17
+ # Set when Meta says outright that we are throttled. Until it passes,
18
+ # every job for this account refuses immediately rather than each one
19
+ # rediscovering the block for itself.
20
+ t.datetime :blocked_until
21
+ t.timestamps
22
+ end
23
+
24
+ add_index :instagram_connect_api_budgets, [ :account_id, :bucket, :window_start ],
25
+ unique: true, name: "index_instagram_connect_budgets_unique", if_not_exists: true
26
+ add_index :instagram_connect_api_budgets, [ :account_id, :bucket, :blocked_until ],
27
+ name: "index_instagram_connect_budgets_on_blocked", if_not_exists: true
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ class AddConversationProfileFields < ActiveRecord::Migration[7.1]
2
+ # username and display_name already existed on conversations and nothing ever
3
+ # wrote them, so an inbox showed raw Instagram-scoped ids — a 17-digit number
4
+ # where a person's handle belongs. These are the rest of what a thread row
5
+ # needs to look like a conversation with somebody.
6
+ def change
7
+ add_column :instagram_connect_conversations, :profile_picture_url, :text, if_not_exists: true
8
+ add_column :instagram_connect_conversations, :profile_synced_at, :datetime, if_not_exists: true
9
+
10
+ # Partial indexes are Postgres-only, and this gem ships into whatever
11
+ # database the host runs.
12
+ add_index :instagram_connect_conversations, [ :account_id, :profile_synced_at ],
13
+ name: "index_instagram_connect_conversations_on_profile_sync",
14
+ if_not_exists: true
15
+
16
+ # Cheap header stats, and the input to the 4800 x impressions budget the
17
+ # rate limiter falls back on when Meta sends no usage header.
18
+ add_column :instagram_connect_accounts, :followers_count, :integer, if_not_exists: true
19
+ add_column :instagram_connect_accounts, :media_count, :integer, if_not_exists: true
20
+ end
21
+ end
@@ -0,0 +1,107 @@
1
+ # Configuration reference
2
+
3
+ Every setting lives on `InstagramConnect::Configuration` and is set inside the
4
+ `InstagramConnect.configure` block (usually
5
+ `config/initializers/instagram_connect.rb`):
6
+
7
+ ```ruby
8
+ InstagramConnect.configure do |config|
9
+ config.auth_path = :instagram_login
10
+ # ...
11
+ end
12
+ ```
13
+
14
+ You can also set any of these through `config.instagram_connect = { ... }` in
15
+ your Rails app configuration; the engine (and the railtie) applies each key
16
+ through the matching setter at boot.
17
+
18
+ Several secrets fall back to environment variables, so a host can configure them
19
+ entirely from the environment without editing the initializer. The `configure`
20
+ block runs `validate!`, which currently checks only that `auth_path` is one of
21
+ the known values; missing secrets are reported later, at the point of use, and
22
+ by the `doctor` CLI.
23
+
24
+ ## Core
25
+
26
+ | Setting | Type | Default | Environment fallback | Purpose |
27
+ | --- | --- | --- | --- | --- |
28
+ | `auth_path` | Symbol | `:instagram_login` | `INSTAGRAM_CONNECT_AUTH_PATH` | Which Meta login path and Graph host to use. One of `:instagram_login` (graph.instagram.com, no Facebook Page) or `:facebook_login` (graph.facebook.com, linked Page). Accepts a string and coerces it to a symbol. |
29
+ | `app_id` | String | `nil` | `INSTAGRAM_CONNECT_APP_ID`, then `INSTAGRAM_APP_ID` | Your Meta app's client id. |
30
+ | `app_secret` | String | `nil` | `INSTAGRAM_CONNECT_APP_SECRET`, then `INSTAGRAM_APP_SECRET` | Your Meta app's secret. Used for the OAuth code exchange and for verifying the webhook HMAC signature. |
31
+ | `verify_token` | String | `nil` | `INSTAGRAM_CONNECT_VERIFY_TOKEN`, then `INSTAGRAM_VERIFY_TOKEN` | The token you enter in the Meta webhook dashboard. The GET verification handshake must present this value. |
32
+ | `graph_version` | String | `"v21.0"` | `INSTAGRAM_CONNECT_GRAPH_VERSION` | The Graph API version segment in request URLs. |
33
+ | `redirect_uri` | String | `nil` | `INSTAGRAM_CONNECT_REDIRECT_URI` | Pin the OAuth redirect URI. Meta requires an exact match. When blank, the engine uses its own callback URL. |
34
+
35
+ ## Token storage
36
+
37
+ | Setting | Type | Default | Purpose |
38
+ | --- | --- | --- | --- |
39
+ | `encrypt_tokens` | Boolean | `true` | Encrypt `Account#access_token` at rest with Active Record Encryption. Set to `false` if your app has no encryption configured. When true, run `bin/rails db:encryption:init` once and add the generated keys to your credentials. |
40
+
41
+ ## Rails integration
42
+
43
+ | Setting | Type | Default | Purpose |
44
+ | --- | --- | --- | --- |
45
+ | `parent_controller` | String | `"::ApplicationController"` | The controller the engine's UI controllers inherit from, so they pick up the host layout, auth helpers, and CSRF handling. The webhook controller does not inherit from this. |
46
+ | `authenticate_with` | Callable | `nil` | A lambda run as a `before_action` in the engine's controllers. Put your host's sign-in guard here, for example `-> { authenticate_user! }`. |
47
+ | `current_user_id_resolver` | Callable | resolves `current_user&.id` in controller context | Attributes outbound replies and connected accounts to the acting operator. Runs in controller context. |
48
+ | `inherit_host_layout` | Boolean | `false` | When `false`, the engine renders in its own bundled layout and stylesheet, like an admin engine. Set `true` to render inside your app's `application` layout, then add `<%= instagram_connect_styles %>` to your `<head>`. |
49
+ | `default_per_page` | Integer | `25` | Page size for the inbox and comment lists. |
50
+ | `after_connect_redirect` | String | `"/"` | Where the OAuth callback redirects after an account is connected. The install generator sets this to `/instagram/conversations`. |
51
+
52
+ ## Event hooks
53
+
54
+ Each hook is optional and receives the persisted record (or event hash) after
55
+ the webhook payload has been ingested, so you can layer on notifications, AI
56
+ replies, and the like.
57
+
58
+ | Setting | Type | Default | Receives |
59
+ | --- | --- | --- | --- |
60
+ | `on_message` | Callable | `nil` | The `InstagramConnect::Message` created for an inbound DM (not fired for echoes of your own outbound sends). |
61
+ | `on_comment` | Callable | `nil` | The `InstagramConnect::Comment` recorded from a `comments` change. |
62
+ | `on_postback` | Callable | `nil` | A hash: `{ account_id:, sender_id:, payload:, title: }`. |
63
+
64
+ ## Logging
65
+
66
+ | Setting | Type | Default | Purpose |
67
+ | --- | --- | --- | --- |
68
+ | `logger` | Logger | `Logger.new($stdout)` | Where the gem logs. The token refresh job logs per-account failures here. |
69
+
70
+ ## Theme
71
+
72
+ The gem ships a complete, self-styled UI. Tint it by assigning `config.theme` a
73
+ hash of any subset of the keys below; your values are merged over the defaults
74
+ (`resolved_theme`) and emitted as CSS custom properties by
75
+ `instagram_connect_styles`.
76
+
77
+ ```ruby
78
+ config.theme = { primary: "#0057a8", font: "Inter, system-ui, sans-serif", radius: "12px" }
79
+ ```
80
+
81
+ | Key | Default |
82
+ | --- | --- |
83
+ | `primary` | `#2563eb` |
84
+ | `primary_contrast` | `#ffffff` |
85
+ | `bg` | `#f7f8fa` |
86
+ | `surface` | `#ffffff` |
87
+ | `text` | `#111827` |
88
+ | `muted` | `#6b7280` |
89
+ | `border` | `#e5e7eb` |
90
+ | `radius` | `12px` |
91
+ | `font` | `Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif` |
92
+ | `customer_bubble` | `#f1f3f5` |
93
+ | `staff_bubble` | `#eef2ff` |
94
+ | `ok` | `#16a34a` |
95
+ | `warn` | `#d97706` |
96
+ | `err` | `#dc2626` |
97
+
98
+ ## Media
99
+
100
+ These are defined on the configuration object with the defaults below. They are
101
+ reserved for the inbound and outbound media handling on the roadmap and are not
102
+ yet read by the shipped code.
103
+
104
+ | Setting | Type | Default |
105
+ | --- | --- | --- |
106
+ | `media_max_bytes` | Integer | `26214400` (25 MB) |
107
+ | `allowed_media_types` | Array | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `audio/mpeg`, `audio/aac`, `application/pdf` |