instagram_connect 0.1.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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.rspec +2 -0
  4. data/CHANGELOG.md +30 -0
  5. data/Gemfile +16 -0
  6. data/LICENSE.txt +21 -0
  7. data/MIT-LICENSE +20 -0
  8. data/README.md +145 -0
  9. data/Rakefile +7 -0
  10. data/app/controllers/instagram_connect/application_controller.rb +30 -0
  11. data/app/controllers/instagram_connect/comments_controller.rb +59 -0
  12. data/app/controllers/instagram_connect/conversations_controller.rb +17 -0
  13. data/app/controllers/instagram_connect/messages_controller.rb +19 -0
  14. data/app/controllers/instagram_connect/oauth_controller.rb +43 -0
  15. data/app/controllers/instagram_connect/posts_controller.rb +40 -0
  16. data/app/controllers/instagram_connect/webhooks_controller.rb +54 -0
  17. data/app/jobs/instagram_connect/application_job.rb +4 -0
  18. data/app/jobs/instagram_connect/ingest_job.rb +11 -0
  19. data/app/jobs/instagram_connect/refresh_tokens_job.rb +22 -0
  20. data/app/jobs/instagram_connect/send_message_job.rb +44 -0
  21. data/app/models/instagram_connect/account.rb +36 -0
  22. data/app/models/instagram_connect/application_record.rb +5 -0
  23. data/app/models/instagram_connect/comment.rb +26 -0
  24. data/app/models/instagram_connect/conversation.rb +43 -0
  25. data/app/models/instagram_connect/inbound_message.rb +15 -0
  26. data/app/models/instagram_connect/message.rb +37 -0
  27. data/app/views/instagram_connect/comments/_comment.html.erb +19 -0
  28. data/app/views/instagram_connect/comments/index.html.erb +11 -0
  29. data/app/views/instagram_connect/conversations/_composer.html.erb +15 -0
  30. data/app/views/instagram_connect/conversations/_conversation.html.erb +11 -0
  31. data/app/views/instagram_connect/conversations/_message.html.erb +6 -0
  32. data/app/views/instagram_connect/conversations/index.html.erb +11 -0
  33. data/app/views/instagram_connect/conversations/show.html.erb +12 -0
  34. data/app/views/instagram_connect/posts/index.html.erb +23 -0
  35. data/app/views/instagram_connect/posts/new.html.erb +17 -0
  36. data/app/views/layouts/instagram_connect/application.html.erb +19 -0
  37. data/bin/instagram_connect +6 -0
  38. data/config/routes.rb +28 -0
  39. data/docs/app_review_guide.md +57 -0
  40. data/docs/auth_paths.md +42 -0
  41. data/docs/roadmap.md +27 -0
  42. data/lib/generators/instagram_connect/controllers_generator.rb +16 -0
  43. data/lib/generators/instagram_connect/install/install_generator.rb +40 -0
  44. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt +16 -0
  45. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt +17 -0
  46. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt +16 -0
  47. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt +11 -0
  48. data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt +25 -0
  49. data/lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt +35 -0
  50. data/lib/generators/instagram_connect/views_generator.rb +16 -0
  51. data/lib/instagram_connect/auth/facebook_login.rb +68 -0
  52. data/lib/instagram_connect/auth/instagram_login.rb +73 -0
  53. data/lib/instagram_connect/auth/strategy.rb +70 -0
  54. data/lib/instagram_connect/auth.rb +17 -0
  55. data/lib/instagram_connect/cli.rb +18 -0
  56. data/lib/instagram_connect/client.rb +160 -0
  57. data/lib/instagram_connect/configuration.rb +66 -0
  58. data/lib/instagram_connect/connect.rb +45 -0
  59. data/lib/instagram_connect/doctor.rb +22 -0
  60. data/lib/instagram_connect/engine.rb +28 -0
  61. data/lib/instagram_connect/errors.rb +25 -0
  62. data/lib/instagram_connect/ingest.rb +112 -0
  63. data/lib/instagram_connect/messaging_window.rb +42 -0
  64. data/lib/instagram_connect/railtie.rb +19 -0
  65. data/lib/instagram_connect/result.rb +34 -0
  66. data/lib/instagram_connect/signature_verifier.rb +23 -0
  67. data/lib/instagram_connect/version.rb +3 -0
  68. data/lib/instagram_connect.rb +53 -0
  69. metadata +160 -0
@@ -0,0 +1,36 @@
1
+ module InstagramConnect
2
+ # A connected Instagram professional account. Holds the (encrypted) access
3
+ # token and the identity the Graph client sends as. One row per connected
4
+ # account — the gem supports connecting several.
5
+ class Account < ApplicationRecord
6
+ self.table_name = "instagram_connect_accounts"
7
+
8
+ has_many :conversations, class_name: "InstagramConnect::Conversation",
9
+ foreign_key: :account_id, dependent: :destroy
10
+ has_many :comments, class_name: "InstagramConnect::Comment",
11
+ foreign_key: :account_id, dependent: :destroy
12
+
13
+ validates :ig_user_id, presence: true, uniqueness: true
14
+ validates :auth_path, presence: true
15
+
16
+ scope :active, -> { where(active: true) }
17
+ scope :token_expiring_before, ->(time) { where.not(token_expires_at: nil).where(token_expires_at: ..time) }
18
+
19
+ # Called from the engine initializer (and specs) when token encryption is
20
+ # enabled. Kept as an explicit toggle so a host without Active Record
21
+ # Encryption configured can opt out via config.encrypt_tokens = false.
22
+ def self.enable_token_encryption!
23
+ encrypts :access_token
24
+ end
25
+
26
+ def token_expired?
27
+ token_expires_at.present? && token_expires_at <= Time.current
28
+ end
29
+
30
+ # Refresh via the account's auth strategy and persist the rotated token.
31
+ def refresh_access_token!
32
+ data = InstagramConnect::Auth.for(InstagramConnect.configuration).refresh_token(access_token: access_token)
33
+ update!(access_token: data[:access_token], token_expires_at: data[:expires_at])
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ module InstagramConnect
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ module InstagramConnect
2
+ # A comment on one of the account's media objects. Upserted from the
3
+ # `comments` webhook; moderation state (hidden/replied) is tracked here.
4
+ class Comment < ApplicationRecord
5
+ self.table_name = "instagram_connect_comments"
6
+
7
+ belongs_to :account, class_name: "InstagramConnect::Account"
8
+
9
+ validates :comment_id, presence: true, uniqueness: true
10
+
11
+ scope :visible, -> { where(hidden_at: nil) }
12
+
13
+ # Upsert by Meta comment id — the same comment can arrive more than once.
14
+ def self.record(account:, comment_id:, media_id:, text:, from_username:, parent_id: nil)
15
+ comment = find_or_initialize_by(comment_id: comment_id)
16
+ comment.account = account
17
+ comment.assign_attributes(media_id: media_id, text: text, from_username: from_username, parent_id: parent_id)
18
+ comment.save!
19
+ comment
20
+ end
21
+
22
+ def hidden?
23
+ hidden_at.present?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,43 @@
1
+ module InstagramConnect
2
+ # One DM thread — a connected account talking to one Instagram user (igsid).
3
+ # Threads are shared across the host's operators; ownership lives on the
4
+ # account, not per-operator.
5
+ class Conversation < ApplicationRecord
6
+ self.table_name = "instagram_connect_conversations"
7
+
8
+ belongs_to :account, class_name: "InstagramConnect::Account"
9
+ has_many :messages, class_name: "InstagramConnect::Message",
10
+ foreign_key: :conversation_id, dependent: :destroy
11
+
12
+ validates :igsid, presence: true, uniqueness: { scope: :account_id }
13
+
14
+ scope :unread, -> { where("unread_count > 0") }
15
+ scope :recent, -> { order(Arel.sql("last_message_at IS NULL, last_message_at DESC")) }
16
+
17
+ # Race-safe find-or-create on the unique [account_id, igsid] pair.
18
+ def self.locate(account:, igsid:)
19
+ find_or_create_by!(account_id: account.id, igsid: igsid)
20
+ rescue ActiveRecord::RecordNotUnique
21
+ find_by!(account_id: account.id, igsid: igsid)
22
+ end
23
+
24
+ # Denormalizes the thread summary + unread count atomically (SQL-side) so
25
+ # concurrent inbound writes can't lose an unread increment.
26
+ def register_message(message)
27
+ stamp = message.created_at || Time.current
28
+ if message.inbound?
29
+ self.class.where(id: id).update_all([
30
+ "last_message_at = ?, last_message_preview = ?, last_inbound_at = ?, " \
31
+ "unread_count = unread_count + 1, updated_at = ?",
32
+ stamp, message.preview, stamp, Time.current
33
+ ])
34
+ else
35
+ self.class.where(id: id).update_all([
36
+ "last_message_at = ?, last_message_preview = ?, updated_at = ?",
37
+ stamp, message.preview, Time.current
38
+ ])
39
+ end
40
+ reload
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ module InstagramConnect
2
+ # At-least-once dedupe ledger keyed by Meta's message id. Lets the webhook
3
+ # (and any future backfill/poll) run without double-processing a message.
4
+ class InboundMessage < ApplicationRecord
5
+ self.table_name = "instagram_connect_inbound_messages"
6
+
7
+ # Returns true only the first time a given message id is claimed.
8
+ def self.claim(ig_message_id:, account_id: nil)
9
+ create!(ig_message_id: ig_message_id, account_id: account_id, processed_at: Time.current)
10
+ true
11
+ rescue ActiveRecord::RecordNotUnique
12
+ false
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ module InstagramConnect
2
+ # One message bubble in a DM thread (or a story reply mirrored as a DM).
3
+ class Message < ApplicationRecord
4
+ self.table_name = "instagram_connect_messages"
5
+
6
+ DIRECTIONS = %w[inbound outbound].freeze
7
+ STATUSES = %w[received pending sending sent failed].freeze
8
+ KINDS = %w[dm story_reply reaction].freeze
9
+ SOURCES = %w[inbound manual api operator_app].freeze
10
+ MEDIA_STATUSES = %w[none pending downloadable attached unavailable].freeze
11
+ PREVIEW_LIMIT = 140
12
+
13
+ belongs_to :conversation, class_name: "InstagramConnect::Conversation"
14
+
15
+ validates :direction, inclusion: { in: DIRECTIONS }
16
+ validates :status, inclusion: { in: STATUSES }
17
+ validates :kind, inclusion: { in: KINDS }
18
+
19
+ scope :inbound, -> { where(direction: "inbound") }
20
+ scope :outbound, -> { where(direction: "outbound") }
21
+ scope :chronological, -> { order(:created_at, :id) }
22
+
23
+ def inbound?
24
+ direction == "inbound"
25
+ end
26
+
27
+ def outbound?
28
+ direction == "outbound"
29
+ end
30
+
31
+ # Short summary used for the inbox row preview.
32
+ def preview
33
+ return body.to_s.truncate(PREVIEW_LIMIT) if body.present?
34
+ "[#{kind}]"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ <li class="ic-comment <%= 'ic-comment--hidden' if comment.hidden? %>" id="<%= dom_id(comment) %>">
2
+ <span class="ic-comment__author"><%= comment.from_username %></span>
3
+ <span class="ic-comment__text"><%= comment.text %></span>
4
+
5
+ <div class="ic-comment__actions">
6
+ <%= form_with url: reply_comment_path(comment), method: :post, class: "ic-comment__reply" do |f| %>
7
+ <%= f.text_field :text, placeholder: "Reply…", class: "ic-comment__reply-input" %>
8
+ <%= f.submit "Reply" %>
9
+ <% end %>
10
+
11
+ <% if comment.hidden? %>
12
+ <%= button_to "Unhide", unhide_comment_path(comment), method: :post %>
13
+ <% else %>
14
+ <%= button_to "Hide", hide_comment_path(comment), method: :post %>
15
+ <% end %>
16
+
17
+ <%= button_to "Delete", comment_path(comment), method: :delete, data: { turbo_confirm: "Delete this comment?" } %>
18
+ </div>
19
+ </li>
@@ -0,0 +1,11 @@
1
+ <section class="ic-comments">
2
+ <h1 class="ic-comments__title">Comments</h1>
3
+
4
+ <% if @comments.any? %>
5
+ <ul class="ic-comments__list" id="instagram_connect_comments">
6
+ <%= render partial: "comment", collection: @comments, as: :comment %>
7
+ </ul>
8
+ <% else %>
9
+ <p class="ic-empty">No comments captured yet. They appear here as people comment on your posts.</p>
10
+ <% end %>
11
+ </section>
@@ -0,0 +1,15 @@
1
+ <% if window.open? %>
2
+ <%= form_with url: conversation_messages_path(conversation), method: :post, class: "ic-composer" do |f| %>
3
+ <%= f.text_area :body, class: "ic-composer__input", placeholder: "Write a reply…", rows: 2 %>
4
+ <% if window.state == :human_agent %>
5
+ <p class="ic-composer__note">
6
+ Outside the 24-hour window — this reply is sent under the human-agent tag (valid up to 7 days).
7
+ </p>
8
+ <% end %>
9
+ <%= f.submit "Send", class: "ic-composer__send" %>
10
+ <% end %>
11
+ <% else %>
12
+ <p class="ic-composer__closed">
13
+ The reply window has closed. The customer must message you again before you can reply.
14
+ </p>
15
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <li class="ic-conversation" id="<%= dom_id(conversation) %>">
2
+ <%= link_to conversation_path(conversation), class: "ic-conversation__link" do %>
3
+ <span class="ic-conversation__name">
4
+ <%= conversation.display_name.presence || conversation.username.presence || conversation.igsid %>
5
+ </span>
6
+ <span class="ic-conversation__preview"><%= conversation.last_message_preview %></span>
7
+ <% if conversation.unread_count.positive? %>
8
+ <span class="ic-conversation__unread"><%= conversation.unread_count %></span>
9
+ <% end %>
10
+ <% end %>
11
+ </li>
@@ -0,0 +1,6 @@
1
+ <div class="ic-message ic-message--<%= message.direction %>" id="<%= dom_id(message) %>">
2
+ <div class="ic-message__body"><%= message.body.presence || "[#{message.kind}]" %></div>
3
+ <span class="ic-message__meta">
4
+ <%= message.status %><%= " · #{message.failure_reason}" if message.failure_reason.present? %>
5
+ </span>
6
+ </div>
@@ -0,0 +1,11 @@
1
+ <section class="ic-inbox">
2
+ <h1 class="ic-inbox__title">Instagram inbox</h1>
3
+
4
+ <% if @conversations.any? %>
5
+ <ul class="ic-conversations" id="instagram_connect_conversations">
6
+ <%= render @conversations %>
7
+ </ul>
8
+ <% else %>
9
+ <p class="ic-empty">No conversations yet. They appear here as soon as someone messages your account.</p>
10
+ <% end %>
11
+ </section>
@@ -0,0 +1,12 @@
1
+ <section class="ic-thread-view">
2
+ <p class="ic-thread-view__back"><%= link_to "← Inbox", conversations_path %></p>
3
+ <h1 class="ic-thread-view__title">
4
+ <%= @conversation.display_name.presence || @conversation.username.presence || @conversation.igsid %>
5
+ </h1>
6
+
7
+ <div class="ic-thread" id="instagram_connect_messages">
8
+ <%= render partial: "message", collection: @messages, as: :message %>
9
+ </div>
10
+
11
+ <%= render "composer", conversation: @conversation, window: @window %>
12
+ </section>
@@ -0,0 +1,23 @@
1
+ <section class="ic-posts">
2
+ <h1 class="ic-posts__title">Your posts</h1>
3
+ <p class="ic-posts__actions"><%= link_to "Publish a post", new_post_path, class: "ic-posts__new" %></p>
4
+
5
+ <% if @quota_usage %>
6
+ <p class="ic-posts__quota">Published in the last 24 hours: <%= @quota_usage %> / 100</p>
7
+ <% end %>
8
+
9
+ <% if @media.any? %>
10
+ <ul class="ic-posts__list" id="instagram_connect_media">
11
+ <% @media.each do |item| %>
12
+ <li class="ic-post" id="ic-media-<%= item["id"] %>">
13
+ <span class="ic-post__caption"><%= item["caption"] %></span>
14
+ <% if item["permalink"].present? %>
15
+ <%= link_to "View on Instagram", item["permalink"], class: "ic-post__link" %>
16
+ <% end %>
17
+ </li>
18
+ <% end %>
19
+ </ul>
20
+ <% else %>
21
+ <p class="ic-empty">No posts published through the API yet.</p>
22
+ <% end %>
23
+ </section>
@@ -0,0 +1,17 @@
1
+ <section class="ic-publish">
2
+ <h1 class="ic-publish__title">Publish a post</h1>
3
+
4
+ <%= form_with url: posts_path, method: :post, class: "ic-publish__form" do |f| %>
5
+ <div class="ic-field">
6
+ <%= f.label :image_url, "Image URL (public HTTPS)" %>
7
+ <%= f.url_field :image_url, required: true, class: "ic-field__input" %>
8
+ </div>
9
+ <div class="ic-field">
10
+ <%= f.label :caption %>
11
+ <%= f.text_area :caption, rows: 3, class: "ic-field__input" %>
12
+ </div>
13
+ <%= f.submit "Publish", class: "ic-publish__submit" %>
14
+ <% end %>
15
+
16
+ <p class="ic-publish__hint">The image must be reachable at a public HTTPS URL at publish time.</p>
17
+ </section>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Instagram — instagram_connect</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= yield :head %>
8
+ </head>
9
+ <body class="ic-body">
10
+ <nav class="ic-nav">
11
+ <%= link_to "Inbox", conversations_path, class: "ic-nav__link" %>
12
+ <%= link_to "Comments", comments_path, class: "ic-nav__link" %>
13
+ <%= link_to "Publish", posts_path, class: "ic-nav__link" %>
14
+ </nav>
15
+ <% if flash[:notice] %><p class="ic-flash ic-flash--notice"><%= flash[:notice] %></p><% end %>
16
+ <% if flash[:alert] %><p class="ic-flash ic-flash--alert"><%= flash[:alert] %></p><% end %>
17
+ <main class="ic-main"><%= yield %></main>
18
+ </body>
19
+ </html>
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "instagram_connect"
4
+ require "instagram_connect/cli"
5
+
6
+ InstagramConnect::CLI.start(ARGV)
data/config/routes.rb ADDED
@@ -0,0 +1,28 @@
1
+ InstagramConnect::Engine.routes.draw do
2
+ # Meta webhook: GET verify handshake, POST event delivery.
3
+ get "webhooks", to: "webhooks#verify"
4
+ post "webhooks", to: "webhooks#receive"
5
+
6
+ # OAuth connect flow.
7
+ get "oauth/start", to: "oauth#start"
8
+ get "oauth/callback", to: "oauth#callback"
9
+
10
+ # DM inbox.
11
+ resources :conversations, only: %i[index show] do
12
+ resources :messages, only: :create
13
+ end
14
+
15
+ # Comment moderation.
16
+ resources :comments, only: %i[index destroy] do
17
+ member do
18
+ post :reply
19
+ post :hide
20
+ post :unhide
21
+ end
22
+ end
23
+
24
+ # Publishing.
25
+ resources :posts, only: %i[index new create]
26
+
27
+ root to: "conversations#index"
28
+ end
@@ -0,0 +1,57 @@
1
+ # Meta App Review checklist
2
+
3
+ To message real Instagram users (anyone without a role on your Meta app) you need
4
+ **Advanced Access**, which requires **App Review** *and* **Business Verification**.
5
+ Until then, development/standard access lets you message only accounts you add as
6
+ app roles/testers (~25). Budget weeks — the queue is slow and each rejection adds days.
7
+
8
+ ## Permissions to request
9
+
10
+ **Instagram Login path** (`graph.instagram.com`):
11
+
12
+ - `instagram_business_basic`
13
+ - `instagram_business_manage_messages` — DMs
14
+ - `instagram_business_manage_comments` — comment moderation
15
+ - `instagram_business_content_publish` — publishing
16
+ - Human Agent — only if you need to reply beyond the 24-hour window (7-day human-agent
17
+ replies)
18
+
19
+ **Facebook Login path** (`graph.facebook.com`):
20
+
21
+ - `instagram_basic`, `instagram_manage_messages`, `instagram_manage_comments`,
22
+ `instagram_content_publish`
23
+ - `pages_show_list`, `pages_manage_metadata` (to subscribe the Page to webhooks),
24
+ `pages_read_engagement`
25
+
26
+ > Scope names in the `instagram_business_*` family were finalized on 27 Jan 2025 —
27
+ > confirm the exact current strings in Meta's live Permissions Reference before you
28
+ > submit.
29
+
30
+ ## Before you submit
31
+
32
+ 1. **Business Verification** in Meta Business Manager (legal name, address, and a
33
+ verifiable document / domain / phone).
34
+ 2. A **public Privacy Policy URL** that clearly describes the data each requested
35
+ permission touches.
36
+ 3. App set to **Live** mode, with an app icon and category.
37
+ 4. **Test credentials + step-by-step reviewer instructions.**
38
+ 5. A **screencast per permission** in English, showing the real end-to-end flow
39
+ (log in → connect the IG account → the actual DM read/send / comment / publish),
40
+ with narration or captions.
41
+
42
+ ## Common rejection reasons
43
+
44
+ - Screencasts that use dummy data, lack narration, or don't actually demonstrate the
45
+ permission in use.
46
+ - Requesting more scopes than the demoed use case needs.
47
+ - A privacy policy that isn't publicly reachable or doesn't cover the requested data.
48
+ - No clear answer to "why does this app need this permission."
49
+ - App misconfiguration (not Live, missing test users, wrong app type).
50
+ - For messaging: missing opt-out/consent flows or inadequate webhook handling.
51
+
52
+ ## Webhook subscription
53
+
54
+ Point the webhook at `https://<your-host>/instagram/webhooks`, set the verify token to
55
+ match `config.verify_token`, and subscribe: `messages`, `messaging_postbacks`,
56
+ `comments`, `mentions`. Meta must reach the URL publicly — localhost won't receive
57
+ callbacks; use a staging host or a tunnel while developing.
@@ -0,0 +1,42 @@
1
+ # Auth paths
2
+
3
+ `instagram_connect` supports both of Meta's Instagram integration paths. Pick one via
4
+ `config.auth_path`. They differ in the Graph host, whether a Facebook Page is required,
5
+ the scope names, and the token lifetime.
6
+
7
+ ## `:instagram_login` — Instagram API with Instagram Login
8
+
9
+ - **Host:** `graph.instagram.com`
10
+ - **Facebook Page:** not required — the operator logs in with Instagram credentials.
11
+ - **Scopes:** `instagram_business_basic`, `instagram_business_manage_messages`,
12
+ `instagram_business_manage_comments`, `instagram_business_content_publish`.
13
+ - **Tokens:** a 60-day long-lived token, refreshable (the `RefreshTokensJob` handles
14
+ this — run it daily).
15
+ - **Use when:** you're a single business connecting your own account, or you want the
16
+ lightest setup. This is Meta's recommended path for new integrations.
17
+
18
+ ## `:facebook_login` — Instagram API with Facebook Login
19
+
20
+ - **Host:** `graph.facebook.com`
21
+ - **Facebook Page:** required — the Instagram professional account must be linked to a
22
+ Facebook Page inside Meta Business Manager.
23
+ - **Scopes:** `instagram_basic`, `instagram_manage_messages`,
24
+ `instagram_manage_comments`, `instagram_content_publish`, `pages_show_list`,
25
+ `pages_manage_metadata`, `pages_read_engagement`.
26
+ - **Tokens:** long-lived Page / Business-Manager System-User tokens, effectively
27
+ non-expiring (the refresh job treats them as a no-op).
28
+ - **Use when:** your account is already managed through a Facebook Page + Business
29
+ Manager, or you want the most durable server-to-server token story.
30
+
31
+ ## Switching
32
+
33
+ Change `config.auth_path` and re-run the OAuth connect flow
34
+ (`GET /instagram/oauth/start`). Existing `Account` rows store the path they were
35
+ connected with. An app uses one path at a time.
36
+
37
+ ## Adding a new path
38
+
39
+ Strategies are plain objects registered in `InstagramConnect::Auth::STRATEGIES` — the
40
+ same config-symbol dispatch used across the house gems. Implement `graph_host`,
41
+ `scopes`, `authorize_url`, `exchange_code`, and `refresh_token` on a subclass of
42
+ `InstagramConnect::Auth::Strategy` and add it to the registry.
data/docs/roadmap.md ADDED
@@ -0,0 +1,27 @@
1
+ # Roadmap
2
+
3
+ ## v0.1 (current)
4
+
5
+ - Both auth paths (Instagram Login / Facebook Login) via pluggable strategies
6
+ - Graph client (DMs, comments, publishing, reads, media) returning `Result`
7
+ - OAuth connect + encrypted-token accounts + scheduled token refresh
8
+ - HMAC-verified webhook + ingest (DMs incl. echoes, comments, postbacks) with dedupe
9
+ - Messaging-window enforcement (24h / 7d human-agent) + at-most-once send job
10
+ - Mounted inbox UI: DM inbox + composer, comment moderation, image publishing
11
+ - Generators (`install`, `views`, `controllers`) + `doctor` CLI
12
+ - 100% line + branch coverage on business logic
13
+
14
+ ## v0.2 (next)
15
+
16
+ - **Turbo realtime broadcasting** — inbox rows and message bubbles update live via
17
+ Turbo Streams from model callbacks (no polling).
18
+ - **Active Storage media** — inbound media fetch + attach (MIME sniff + size checks),
19
+ and outbound file send via a signed, TTL'd blob URL that Meta fetches.
20
+
21
+ ## Later
22
+
23
+ - Reels / video / carousel / story publishing (container status polling).
24
+ - Story mentions and replies (they arrive as DM events).
25
+ - Multiple connected accounts per host with per-account auth paths.
26
+ - Keyset pagination for high-volume inboxes.
27
+ - Optional AI reply drafting hook.
@@ -0,0 +1,16 @@
1
+ require "rails/generators"
2
+
3
+ module InstagramConnect
4
+ module Generators
5
+ # `rails g instagram_connect:controllers` — copy the engine's controllers
6
+ # into the host app for deeper customization. Overridden controllers take
7
+ # precedence over the engine's.
8
+ class ControllersGenerator < ::Rails::Generators::Base
9
+ source_root InstagramConnect::Engine.root.join("app/controllers").to_s
10
+
11
+ def copy_controllers
12
+ directory "instagram_connect", "app/controllers/instagram_connect"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module InstagramConnect
5
+ module Generators
6
+ # `rails g instagram_connect:install` — writes the initializer, mounts the
7
+ # engine at /instagram, and copies the database migrations.
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ MIGRATIONS = %w[
14
+ create_instagram_connect_accounts
15
+ create_instagram_connect_conversations
16
+ create_instagram_connect_messages
17
+ create_instagram_connect_inbound_messages
18
+ create_instagram_connect_comments
19
+ ].freeze
20
+
21
+ def self.next_migration_number(dirname)
22
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
23
+ end
24
+
25
+ def create_initializer
26
+ template "instagram_connect.rb.tt", "config/initializers/instagram_connect.rb"
27
+ end
28
+
29
+ def mount_engine
30
+ route %(mount InstagramConnect::Engine => "/instagram")
31
+ end
32
+
33
+ def copy_migrations
34
+ MIGRATIONS.each do |name|
35
+ migration_template "#{name}.rb.tt", "db/migrate/#{name}.rb"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,16 @@
1
+ class CreateInstagramConnectAccounts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :instagram_connect_accounts do |t|
4
+ t.string :auth_path, null: false
5
+ t.string :ig_user_id, null: false
6
+ t.string :page_id
7
+ t.string :username
8
+ t.text :access_token
9
+ t.datetime :token_expires_at
10
+ t.boolean :active, default: true, null: false
11
+ t.bigint :connected_by_id
12
+ t.timestamps
13
+ end
14
+ add_index :instagram_connect_accounts, :ig_user_id, unique: true
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ class CreateInstagramConnectComments < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :instagram_connect_comments do |t|
4
+ t.bigint :account_id, null: false
5
+ t.string :media_id
6
+ t.string :comment_id, null: false
7
+ t.string :parent_id
8
+ t.string :from_username
9
+ t.text :text
10
+ t.datetime :hidden_at
11
+ t.datetime :replied_at
12
+ t.timestamps
13
+ end
14
+ add_index :instagram_connect_comments, :comment_id, unique: true
15
+ add_index :instagram_connect_comments, :account_id
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ class CreateInstagramConnectConversations < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :instagram_connect_conversations do |t|
4
+ t.bigint :account_id, null: false
5
+ t.string :igsid, null: false
6
+ t.string :username
7
+ t.string :display_name
8
+ t.datetime :last_message_at
9
+ t.datetime :last_inbound_at
10
+ t.string :last_message_preview
11
+ t.integer :unread_count, default: 0, null: false
12
+ t.timestamps
13
+ end
14
+ add_index :instagram_connect_conversations, [ :account_id, :igsid ], unique: true
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ class CreateInstagramConnectInboundMessages < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :instagram_connect_inbound_messages do |t|
4
+ t.string :ig_message_id, null: false
5
+ t.bigint :account_id
6
+ t.datetime :processed_at
7
+ t.timestamps
8
+ end
9
+ add_index :instagram_connect_inbound_messages, :ig_message_id, unique: true
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ class CreateInstagramConnectMessages < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :instagram_connect_messages do |t|
4
+ t.bigint :conversation_id, null: false
5
+ t.string :direction, null: false
6
+ t.string :status, null: false
7
+ t.string :kind, default: "dm", null: false
8
+ t.string :source
9
+ t.text :body
10
+ t.string :ig_message_id
11
+ t.string :message_tag
12
+ t.bigint :sent_by_id
13
+ t.string :media_status, default: "none", null: false
14
+ t.string :media_mime
15
+ t.string :media_filename
16
+ t.integer :media_size
17
+ t.string :media_error
18
+ t.string :error_message
19
+ t.string :failure_reason
20
+ t.timestamps
21
+ end
22
+ add_index :instagram_connect_messages, :conversation_id
23
+ add_index :instagram_connect_messages, :ig_message_id, unique: true
24
+ end
25
+ end