instagram_connect 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +36 -0
  3. data/CHANGELOG.md +81 -0
  4. data/Gemfile +4 -0
  5. data/README.md +328 -73
  6. data/app/controllers/instagram_connect/comments_controller.rb +1 -1
  7. data/app/controllers/instagram_connect/posts_controller.rb +1 -1
  8. data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
  9. data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
  10. data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
  11. data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
  12. data/app/jobs/instagram_connect/send_message_job.rb +44 -16
  13. data/app/models/instagram_connect/account.rb +27 -1
  14. data/app/models/instagram_connect/api_budget.rb +22 -0
  15. data/app/models/instagram_connect/conversation.rb +99 -11
  16. data/app/models/instagram_connect/inbound_message.rb +11 -3
  17. data/app/models/instagram_connect/insight_snapshot.rb +43 -0
  18. data/app/models/instagram_connect/media_item.rb +38 -0
  19. data/app/models/instagram_connect/mention.rb +37 -0
  20. data/app/models/instagram_connect/message.rb +34 -2
  21. data/app/models/instagram_connect/message_attachment.rb +53 -0
  22. data/app/models/instagram_connect/message_reaction.rb +33 -0
  23. data/app/models/instagram_connect/webhook_event.rb +67 -0
  24. data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
  25. data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
  26. data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
  27. data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
  28. data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
  29. data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
  30. data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
  31. data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
  32. data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
  33. data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
  34. data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
  35. data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
  36. data/docs/CONFIGURATION.md +107 -0
  37. data/lib/instagram_connect/client.rb +109 -3
  38. data/lib/instagram_connect/configuration.rb +10 -0
  39. data/lib/instagram_connect/engine.rb +1 -0
  40. data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
  41. data/lib/instagram_connect/ingest/envelope.rb +31 -0
  42. data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
  43. data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
  44. data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
  45. data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
  46. data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
  47. data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
  48. data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
  49. data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
  50. data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
  51. data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
  52. data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
  53. data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
  54. data/lib/instagram_connect/ingest/registry.rb +93 -0
  55. data/lib/instagram_connect/ingest/summary.rb +75 -0
  56. data/lib/instagram_connect/ingest.rb +27 -105
  57. data/lib/instagram_connect/media/attaching.rb +57 -0
  58. data/lib/instagram_connect/media/limits.rb +46 -0
  59. data/lib/instagram_connect/rate_limiter.rb +88 -0
  60. data/lib/instagram_connect/result.rb +26 -6
  61. data/lib/instagram_connect/text_splitter.rb +51 -0
  62. data/lib/instagram_connect/usage.rb +68 -0
  63. data/lib/instagram_connect/version.rb +1 -1
  64. data/lib/instagram_connect.rb +23 -0
  65. metadata +60 -2
  66. data/MIT-LICENSE +0 -20
@@ -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` |
@@ -33,6 +33,61 @@ module InstagramConnect
33
33
  })
34
34
  end
35
35
 
36
+ # Typing indicators and read receipts. Meta is explicit that a sender action
37
+ # request must carry ONLY the recipient and the action — bundling it with a
38
+ # message silently drops one of the two.
39
+ def send_sender_action(recipient_id:, action:)
40
+ post("/me/messages", { recipient: { id: recipient_id }, sender_action: action })
41
+ end
42
+
43
+ def send_quick_replies(recipient_id:, text:, replies:, tag: nil)
44
+ quick_replies = Array(replies).map do |reply|
45
+ { content_type: "text", title: reply[:title].to_s[0, 20], payload: reply[:payload].to_s }
46
+ end
47
+ send_message(recipient: { id: recipient_id },
48
+ message: { text: text, quick_replies: quick_replies }, tag: tag)
49
+ end
50
+
51
+ def send_attachment(recipient_id:, attachment_id:, tag: nil)
52
+ send_message(recipient: { id: recipient_id },
53
+ message: { attachment: { payload: { attachment_id: attachment_id } } }, tag: tag)
54
+ end
55
+
56
+ # Uploads bytes to Meta and returns a reusable attachment id.
57
+ #
58
+ # Preferred over handing Meta a signed URL to the host's storage: a signed
59
+ # URL is a bearer capability for a customer's file, sitting on the public
60
+ # internet for as long as its TTL, fetched from an address we do not
61
+ # control. This moves the bytes over an authenticated POST instead, and the
62
+ # returned id can be reused rather than re-uploading the same file.
63
+ # +file+ must be a File or Tempfile — HTTParty builds the multipart body
64
+ # from objects that expose a path, which is also what ActiveStorage's
65
+ # blob.open yields.
66
+ def upload_attachment(file:, type: "image")
67
+ body = {
68
+ platform: "instagram",
69
+ message: { attachment: { type: type, payload: { is_reusable: true } } }.to_json,
70
+ filedata: file
71
+ }
72
+ post_multipart("/me/message_attachments", body)
73
+ end
74
+
75
+ # Ice breakers and the persistent menu both live on the messenger profile.
76
+ def set_messenger_profile(**fields)
77
+ post("/me/messenger_profile", fields.merge(platform: "instagram"))
78
+ end
79
+
80
+ def messenger_profile(fields:)
81
+ get("/me/messenger_profile", { fields: Array(fields).join(","), platform: "instagram" })
82
+ end
83
+
84
+ def delete_messenger_profile(fields:)
85
+ parse(HTTParty.delete(url("/me/messenger_profile"),
86
+ headers: bearer.merge("Content-Type" => "application/json"),
87
+ body: { fields: Array(fields), platform: "instagram" }.to_json,
88
+ timeout: TIMEOUT))
89
+ end
90
+
36
91
  # One-time private reply to a comment (comment -> DM), valid 7 days.
37
92
  def private_reply(comment_id:, text:)
38
93
  post("/me/messages", { recipient: { comment_id: comment_id }, message: { text: text } })
@@ -85,6 +140,13 @@ module InstagramConnect
85
140
  get("/#{media_id}/insights", { metric: Array(metrics).join(",") })
86
141
  end
87
142
 
143
+ # The connected account's own profile. A different call from #profile,
144
+ # which looks up the person on the other end of a thread.
145
+ def account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url
146
+ followers_count media_count])
147
+ get("/#{require_ig_user_id(ig_user_id)}", { fields: Array(fields).join(",") })
148
+ end
149
+
88
150
  def profile(igsid:, fields: %w[name username profile_pic])
89
151
  get("/#{igsid}", { fields: Array(fields).join(",") })
90
152
  end
@@ -95,6 +157,38 @@ module InstagramConnect
95
157
  get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" })
96
158
  end
97
159
 
160
+ # Walks a Graph collection to the end, following Meta's own cursors.
161
+ #
162
+ # Offsets are not safe here: a collection can shift between calls, so an
163
+ # offset silently skips or repeats rows. +limit_pages+ exists because these
164
+ # collections can be unbounded and every page spends rate budget.
165
+ def each_page(path, query = {}, limit_pages: 25)
166
+ cursor = nil
167
+ pages = 0
168
+
169
+ while pages < limit_pages
170
+ page_query = cursor ? query.merge(after: cursor) : query
171
+ result = get(path, page_query)
172
+ return result unless result.success?
173
+
174
+ yield result
175
+ pages += 1
176
+ cursor = result.next_cursor
177
+ break if cursor.nil?
178
+ end
179
+
180
+ Result.ok(data: { "pages" => pages })
181
+ end
182
+
183
+ # Every record across every page, for callers that just want the list.
184
+ def collect(path, query = {}, limit_pages: 25)
185
+ records = []
186
+ result = each_page(path, query, limit_pages: limit_pages) { |page| records.concat(page.records) }
187
+ return result if result.failure?
188
+
189
+ Result.ok(data: { "data" => records })
190
+ end
191
+
98
192
  def fetch_media_binary(url:)
99
193
  response = HTTParty.get(url, headers: bearer, timeout: TIMEOUT, follow_redirects: true)
100
194
  unless response.success?
@@ -135,6 +229,13 @@ module InstagramConnect
135
229
  parse(HTTParty.delete(url(path), headers: bearer, timeout: TIMEOUT))
136
230
  end
137
231
 
232
+ # multipart/form-data rather than JSON. The Attachment Upload API takes raw
233
+ # bytes, which a JSON body cannot carry.
234
+ def post_multipart(path, body)
235
+ parse(HTTParty.post(url(path), headers: bearer, body: body,
236
+ multipart: true, timeout: TIMEOUT))
237
+ end
238
+
138
239
  def url(path)
139
240
  "#{strategy.graph_host}/#{config.graph_version}#{path}"
140
241
  end
@@ -146,14 +247,19 @@ module InstagramConnect
146
247
  def parse(response)
147
248
  data = response.parsed_response
148
249
  data = {} unless data.is_a?(Hash)
250
+ usage = Usage.from_headers(response.headers)
149
251
  if response.success?
150
- Result.ok(id: data["message_id"] || data["id"], data: data)
252
+ Result.ok(id: data["message_id"] || data["id"], data: data, usage: usage)
151
253
  else
152
254
  err = data["error"].is_a?(Hash) ? data["error"] : {}
153
255
  Result.error(err["message"] || "HTTP #{response.code}",
154
256
  error_code: err["code"],
155
- retry_after: err.dig("error_data", "retry_after"),
156
- data: data)
257
+ # Meta names the wait in the error when it has one; when it
258
+ # does not, the usage header's estimate is the next best
259
+ # thing and is better than an invented backoff.
260
+ retry_after: err.dig("error_data", "retry_after") || usage&.retry_after,
261
+ data: data,
262
+ usage: usage)
157
263
  end
158
264
  end
159
265
  end
@@ -41,6 +41,16 @@ module InstagramConnect
41
41
  attr_reader :auth_path
42
42
  attr_writer :theme
43
43
 
44
+ # A copy of this configuration pinned to one auth path. Accounts store the
45
+ # path they were connected with, and a host can have both — so the global
46
+ # setting is the default for *new* connections, never an override on an
47
+ # existing one.
48
+ def for_auth_path(path)
49
+ return self if path.blank? || path.to_sym == auth_path
50
+
51
+ dup.tap { |copy| copy.auth_path = path }
52
+ end
53
+
44
54
  def initialize
45
55
  @auth_path = (ENV["INSTAGRAM_CONNECT_AUTH_PATH"] || "instagram_login").to_sym
46
56
  @app_id = ENV["INSTAGRAM_CONNECT_APP_ID"] || ENV["INSTAGRAM_APP_ID"]
@@ -43,6 +43,7 @@ module InstagramConnect
43
43
  # Encryption configured).
44
44
  config.to_prepare do
45
45
  InstagramConnect::Account.enable_token_encryption! if InstagramConnect.configuration.encrypt_tokens
46
+ InstagramConnect::MessageAttachment.enable_file_attachment! if defined?(ActiveStorage::Blob)
46
47
  end
47
48
  end
48
49
  end
@@ -0,0 +1,151 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module InstagramConnect
5
+ class Ingest
6
+ # Walks a verified webhook payload, banks every event it contains on the
7
+ # ledger, and routes each one to its handler.
8
+ #
9
+ # Two rules shape this class:
10
+ #
11
+ # 1. Nothing is dropped. An event with no handler, or belonging to no
12
+ # connected account, still gets a row. Meta does not re-deliver, so a
13
+ # counter increment would be the only record that something was lost.
14
+ # 2. One bad event cannot poison the batch. Each is wrapped in its own
15
+ # rescue and marked `failed` with its error, leaving the rest to process.
16
+ class Dispatcher
17
+ def initialize(config)
18
+ @config = config
19
+ end
20
+
21
+ def call(payload)
22
+ summary = Summary.new
23
+ Array(payload && payload["entry"]).each do |entry|
24
+ process_entry(payload, entry, summary)
25
+ end
26
+ summary
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :config
32
+
33
+ def process_entry(payload, entry, summary)
34
+ account = account_for(entry)
35
+ entry_id = entry["id"].to_s
36
+ occurred_at = Envelope.time_from(entry["time"])
37
+
38
+ flatten(entry).each do |field, event|
39
+ process_event(
40
+ account: account, object: payload["object"], field: field, event: event,
41
+ entry_id: entry_id, entry_time: occurred_at, summary: summary
42
+ )
43
+ end
44
+ end
45
+
46
+ # Messaging events arrive under `entry.messaging` whatever subscription
47
+ # produced them, so their field is recovered by shape. `standby` wraps the
48
+ # same shapes for a thread this app does not currently control. Changes
49
+ # (comments, mentions, story insights) name their own field.
50
+ def flatten(entry)
51
+ pairs = Array(entry["messaging"]).map { |event| [ Registry.messaging_field_for(event), event ] }
52
+ pairs += Array(entry["standby"]).map { |event| [ "standby", event ] }
53
+ pairs + Array(entry["changes"]).map { |change| [ change["field"].to_s, change ] }
54
+ end
55
+
56
+ def process_event(account:, object:, field:, event:, entry_id:, entry_time:, summary:)
57
+ handler_class = Registry.handler_for(field)
58
+ key = dedupe_key(handler_class, field, event, entry_id)
59
+
60
+ # A handler that needs Meta's id cannot do anything useful without one.
61
+ # An event like this used to be counted as skipped; banking it means an
62
+ # operator can see that Meta sent something malformed.
63
+ if handler_class.respond_to?(:requires_dedupe_key?) && handler_class.requires_dedupe_key? &&
64
+ handler_class.dedupe_key(event).blank?
65
+ record_unhandled(account, object, field, event, entry_id, entry_time, key, summary)
66
+ return
67
+ end
68
+
69
+ record = WebhookEvent.claim(
70
+ account: account, object: object, field: field,
71
+ event_type: field, dedupe_key: key, entry_id: entry_id,
72
+ occurred_at: event_time(event) || entry_time, payload: event,
73
+ handler: handler_class.name
74
+ )
75
+
76
+ if record.nil?
77
+ summary.duplicates += 1
78
+ return
79
+ end
80
+
81
+ if account.nil? || !Registry.handled?(field)
82
+ record.mark_unhandled!
83
+ summary.unhandled += 1
84
+ return
85
+ end
86
+
87
+ run(handler_class, record, account, object, field, event, entry_id, entry_time, summary)
88
+ end
89
+
90
+ def run(handler_class, record, account, object, field, event, entry_id, entry_time, summary)
91
+ envelope = Envelope.new(
92
+ account: account, object: object, field: field, event_type: field,
93
+ event: event, entry_id: entry_id,
94
+ occurred_at: event_time(event) || entry_time, config: config
95
+ )
96
+ handler = handler_class.new(envelope)
97
+ subject = handler.call
98
+ record.mark_processed!(subject)
99
+ summary.count(field)
100
+ deliver(handler.notification, summary)
101
+ rescue StandardError => e
102
+ record.mark_failed!(e)
103
+ summary.failed += 1
104
+ end
105
+
106
+ # The host callback runs outside the handler, in its own rescue. The rows
107
+ # are already committed at this point, so a host hook raising must not
108
+ # flip the event to failed — it would be replayed and duplicate the data.
109
+ def deliver(notification, summary)
110
+ return if notification.nil?
111
+
112
+ handler, argument = notification
113
+ handler.call(argument) if handler.respond_to?(:call)
114
+ rescue StandardError => e
115
+ summary.callback_errors += 1
116
+ config.logger&.error("[InstagramConnect] host callback raised: #{e.class}: #{e.message}")
117
+ end
118
+
119
+ def record_unhandled(account, object, field, event, entry_id, entry_time, key, summary)
120
+ record = WebhookEvent.claim(
121
+ account: account, object: object, field: field, event_type: field,
122
+ dedupe_key: key, entry_id: entry_id,
123
+ occurred_at: event_time(event) || entry_time, payload: event,
124
+ handler: Handlers::Unknown.name
125
+ )
126
+ if record.nil?
127
+ summary.duplicates += 1
128
+ else
129
+ record.mark_unhandled!
130
+ summary.unhandled += 1
131
+ end
132
+ end
133
+
134
+ def dedupe_key(handler_class, field, event, entry_id)
135
+ handler_class.dedupe_key(event).presence ||
136
+ Digest::SHA256.hexdigest("#{field}:#{entry_id}:#{JSON.generate(event)}")
137
+ end
138
+
139
+ def event_time(event)
140
+ Envelope.time_from(event["timestamp"])
141
+ end
142
+
143
+ def account_for(entry)
144
+ id = entry["id"].to_s
145
+ return nil if id.empty?
146
+
147
+ Account.find_by(ig_user_id: id) || Account.find_by(page_id: id)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,31 @@
1
+ module InstagramConnect
2
+ class Ingest
3
+ # One webhook event, plus the context needed to interpret it: which account
4
+ # it belongs to, which field Meta delivered it under, and when it actually
5
+ # happened on the wire (as opposed to when we got round to storing it).
6
+ Envelope = Struct.new(
7
+ :account, :object, :field, :event_type, :event, :entry_id, :occurred_at, :config,
8
+ keyword_init: true
9
+ ) do
10
+ # Anything outside this is not a timestamp Meta could have sent, and
11
+ # storing it would corrupt every ordering that reads occurred_at.
12
+ MAX_SECONDS = 4_102_444_800 # 2100-01-01
13
+
14
+ # Meta timestamps are milliseconds since the epoch. Ordering off this
15
+ # rather than our own created_at is what stops a delayed webhook batch
16
+ # silently reordering a conversation.
17
+ def self.time_from(millis)
18
+ return nil if millis.nil?
19
+
20
+ seconds = millis.to_i / 1000.0
21
+ return nil unless seconds.finite? && seconds >= 0 && seconds <= MAX_SECONDS
22
+
23
+ Time.at(seconds).utc
24
+ end
25
+
26
+ def dig(*keys)
27
+ event.dig(*keys)
28
+ end
29
+ end
30
+ end
31
+ end