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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '00201119165343bb2f5ab1e16f2894f4944fb452b241068691fe0f0ef2858c0e'
4
+ data.tar.gz: 6ad13294368d8130252ada941018415855fc935ce32349be419064ab458aa718
5
+ SHA512:
6
+ metadata.gz: 38c055d770365ef01e4571105a2846bb727bb5f7f7d83ca7dd867ea6cdfadc8c9b70cae2d2005ae68e148aeafbb5799a3be1704d81474ec82979b8464766af2e
7
+ data.tar.gz: cb99273b4e2e03d40a3f07e75494ced6de868a94a1c99468f7b85d04b981718a6de2aeae734f2b9ce5ccbd308c0aa76442a17529dce63f2ce25b6020892f8d07
@@ -0,0 +1,41 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ main ]
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout code
13
+ uses: actions/checkout@v6
14
+
15
+ - name: Set up Ruby
16
+ uses: ruby/setup-ruby@v1
17
+ with:
18
+ ruby-version: "3.3"
19
+ bundler-cache: true
20
+
21
+ - name: Lint code for consistent style
22
+ run: bundle exec rubocop -f github
23
+
24
+ test:
25
+ runs-on: ubuntu-latest
26
+ strategy:
27
+ fail-fast: false
28
+ matrix:
29
+ ruby: [ "3.2", "3.3", "3.4" ]
30
+ steps:
31
+ - name: Checkout code
32
+ uses: actions/checkout@v6
33
+
34
+ - name: Set up Ruby
35
+ uses: ruby/setup-ruby@v1
36
+ with:
37
+ ruby-version: ${{ matrix.ruby }}
38
+ bundler-cache: true
39
+
40
+ - name: Run tests
41
+ run: bundle exec rspec --format progress
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] — Unreleased
8
+
9
+ Initial release. A mountable Rails engine for Instagram over the official Meta Graph API.
10
+
11
+ ### Added
12
+ - Mountable `InstagramConnect::Engine` + `Railtie`, `Configuration` (with `.configure`
13
+ / `validate!`), `Result` value object, and error hierarchy.
14
+ - Both Meta auth paths as pluggable strategies (`:instagram_login`, `:facebook_login`)
15
+ with config-symbol dispatch.
16
+ - Graph API `Client` (DMs, comments, publishing, reads, media) returning `Result`.
17
+ - OAuth connect flow (`Connect` service) → encrypted-token `Account`; scheduled
18
+ `RefreshTokensJob`.
19
+ - HMAC (`X-Hub-Signature-256`) webhook verification + `Ingest` (DMs incl. echoes,
20
+ comments, postbacks) with an `InboundMessage` dedupe ledger and host hooks
21
+ (`on_message` / `on_comment` / `on_postback`).
22
+ - `MessagingWindow` (24h standard / 7d human-agent / closed) and an at-most-once
23
+ `SendMessageJob` that enforces it.
24
+ - Mounted inbox UI: DM inbox + window-aware composer, comment moderation, image
25
+ publishing; engine layout + overridable views.
26
+ - Generators: `install` (initializer + engine mount + migrations), `views`,
27
+ `controllers`.
28
+ - `instagram_connect doctor` CLI + configuration preflight.
29
+ - RSpec suite (in-memory sqlite, WebMock-stubbed Graph API, mounted-engine request
30
+ specs) at 100% line + branch coverage; `rubocop-rails-omakase`; CI on Ruby 3.2–3.4.
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in instagram_connect.gemspec.
4
+ gemspec
5
+
6
+ gem "puma"
7
+
8
+ gem "sqlite3"
9
+ gem "rack-test"
10
+ gem "webmock"
11
+ gem "rspec-rails", "~> 8.0"
12
+ gem "factory_bot"
13
+ gem "simplecov", require: false
14
+
15
+ # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
16
+ gem "rubocop-rails-omakase", require: false
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Kshitiz Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Kshitiz Sinha
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # instagram_connect
2
+
3
+ A mountable Rails engine that connects your app to **Instagram** over the official
4
+ **Meta Graph API** — receive and reply to DMs and comments in real time via
5
+ HMAC-verified webhooks, publish posts, and manage OAuth tokens. Official API only:
6
+ no reverse-engineered automation, no browser bots, so your account stays safe.
7
+
8
+ [![CI](https://github.com/kshtzkr/instagram_connect/actions/workflows/ci.yml/badge.svg)](https://github.com/kshtzkr/instagram_connect/actions/workflows/ci.yml)
9
+
10
+ ## Why official-only
11
+
12
+ Instagram permanently disables real accounts that use reverse-engineered/private-API
13
+ automation, and appeals rarely succeed — you lose the handle, followers, and DM
14
+ history at once. Unlike WhatsApp (where a ban costs a replaceable phone number), an
15
+ Instagram ban costs the whole brand account. This gem only ever speaks the sanctioned
16
+ Graph API.
17
+
18
+ ## Features
19
+
20
+ - **DMs** — receive inbound messages in real time (webhooks), reply with text within
21
+ Meta's 24-hour window (extended to 7 days via the human-agent tag).
22
+ - **Comments** — read, reply to, hide/unhide, and delete comments on your posts.
23
+ - **Publishing** — publish image posts (Reels/video/carousel are on the roadmap).
24
+ - **Two auth paths** — `:instagram_login` (no Facebook Page) or `:facebook_login`
25
+ (linked Page + durable tokens), selected by config. See
26
+ [docs/auth_paths.md](docs/auth_paths.md).
27
+ - **Mounted inbox UI** — a ready inbox, comment moderation, and publish screens you
28
+ can restyle to your own design system.
29
+ - **Secure by default** — encrypted tokens at rest, HMAC-verified webhooks.
30
+
31
+ ## Requirements
32
+
33
+ - Rails ≥ 7.1, Ruby ≥ 3.1
34
+ - Active Storage (media attachments — roadmap) and an Active Job backend
35
+ (Solid Queue, Sidekiq, …) for webhook ingestion + token refresh
36
+ - Active Record Encryption configured for token storage
37
+ (`bin/rails db:encryption:init`), or set `config.encrypt_tokens = false`
38
+ - An Instagram **professional** account (Business or Creator)
39
+
40
+ ## Installation
41
+
42
+ ```ruby
43
+ # Gemfile
44
+ gem "instagram_connect"
45
+ ```
46
+
47
+ ```bash
48
+ bundle install
49
+ bin/rails g instagram_connect:install # initializer + engine mount + migrations
50
+ bin/rails db:migrate
51
+ ```
52
+
53
+ The install generator writes `config/initializers/instagram_connect.rb`, mounts the
54
+ engine at `/instagram`, and copies migrations for all five tables.
55
+
56
+ ## Configuration
57
+
58
+ ```ruby
59
+ # config/initializers/instagram_connect.rb
60
+ InstagramConnect.configure do |config|
61
+ config.auth_path = :facebook_login # or :instagram_login
62
+ config.app_id = Rails.application.credentials.dig(:instagram_connect, :app_id)
63
+ config.app_secret = Rails.application.credentials.dig(:instagram_connect, :app_secret)
64
+ config.verify_token = Rails.application.credentials.dig(:instagram_connect, :verify_token)
65
+ config.graph_version = "v21.0"
66
+
67
+ # Rails integration
68
+ config.parent_controller = "ApplicationController"
69
+ config.authenticate_with = -> { authenticate_user! } # runs in controller context
70
+ config.current_user_id_resolver = -> { current_user&.id } # attributes replies
71
+ config.after_connect_redirect = "/instagram"
72
+
73
+ # Optional host hooks
74
+ config.on_message = ->(message) { NotifyOpsJob.perform_later(message.id) }
75
+ config.on_comment = ->(comment) { }
76
+ end
77
+ ```
78
+
79
+ ## Connecting an account
80
+
81
+ Send an operator to `GET /instagram/oauth/start`. They authorize with Meta and are
82
+ redirected back to `config.after_connect_redirect` with a stored, encrypted
83
+ `InstagramConnect::Account`. Schedule the refresh job daily so tokens stay fresh:
84
+
85
+ ```ruby
86
+ # config/recurring.yml (Solid Queue), or your scheduler of choice
87
+ instagram_connect_token_refresh:
88
+ class: InstagramConnect::RefreshTokensJob
89
+ schedule: every day at 3am
90
+ ```
91
+
92
+ ## Webhooks
93
+
94
+ Point your Meta app's webhook at `https://<your-host>/instagram/webhooks` and use the
95
+ same `verify_token` you configured. Subscribe the fields you need: `messages`,
96
+ `messaging_postbacks`, `comments`, `mentions`. The engine verifies the
97
+ `X-Hub-Signature-256` HMAC on every delivery and ingests off the request path.
98
+
99
+ ## The inbox UI
100
+
101
+ Mounted at `/instagram`: a DM inbox with a window-aware reply composer, a comment
102
+ moderation screen, and a publish screen. To restyle to your app's design system:
103
+
104
+ ```bash
105
+ bin/rails g instagram_connect:views # copies views into app/views/instagram_connect
106
+ bin/rails g instagram_connect:controllers # (optional) copies controllers to override
107
+ ```
108
+
109
+ ## CLI
110
+
111
+ ```bash
112
+ bundle exec instagram_connect doctor # preflight your configuration
113
+ ```
114
+
115
+ ## Meta App Review
116
+
117
+ Messaging real customers requires Advanced Access, which requires **App Review** and
118
+ **Business Verification**. Until approved, you can only message accounts added as
119
+ testers on your Meta app. The full checklist — permissions per auth path, privacy
120
+ policy, screencasts, common rejection reasons — is in
121
+ [docs/app_review_guide.md](docs/app_review_guide.md).
122
+
123
+ ## Constraints (Meta rules, not the gem's)
124
+
125
+ - **24-hour window**: you can only reply within 24h of the customer's last message
126
+ (7 days with the human-agent tag). The gem enforces this and blocks illegal sends.
127
+ - **No cold DMs**: you can't message someone who never messaged you.
128
+ - **Publishing**: 100 API-published posts per rolling 24h; media must be at a public
129
+ HTTPS URL.
130
+
131
+ ## Testing
132
+
133
+ ```bash
134
+ bundle exec rspec # in-memory sqlite, WebMock-stubbed Graph API, 100% coverage
135
+ bundle exec rubocop
136
+ ```
137
+
138
+ ## Roadmap
139
+
140
+ See [docs/roadmap.md](docs/roadmap.md) — next up: Turbo realtime broadcasting and
141
+ Active Storage media (inbound fetch + outbound file send).
142
+
143
+ ## License
144
+
145
+ Released under the [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ require "rspec/core/rake_task"
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
@@ -0,0 +1,30 @@
1
+ module InstagramConnect
2
+ # Base controller for the engine's UI (OAuth, inbox, comments, publishing).
3
+ # Inherits from the host's configured parent controller so it picks up the
4
+ # host layout, auth helpers, and CSRF handling, then runs the configured
5
+ # authentication hook. The webhook controller deliberately does NOT inherit
6
+ # from this — it authenticates by HMAC, not by host session.
7
+ class ApplicationController < InstagramConnect.configuration.parent_controller.constantize
8
+ before_action :authenticate_instagram_connect!
9
+ layout :instagram_connect_layout
10
+
11
+ private
12
+
13
+ # Use the host's application layout by default (so the inbox looks native),
14
+ # or the engine's own bundled layout when the host opts out.
15
+ def instagram_connect_layout
16
+ InstagramConnect.configuration.inherit_host_layout ? "application" : "instagram_connect/application"
17
+ end
18
+
19
+ def authenticate_instagram_connect!
20
+ handler = InstagramConnect.configuration.authenticate_with
21
+ instance_exec(&handler) if handler.respond_to?(:call)
22
+ end
23
+
24
+ def instagram_connect_user_id
25
+ resolver = InstagramConnect.configuration.current_user_id_resolver
26
+ resolver.respond_to?(:call) ? instance_exec(&resolver) : nil
27
+ end
28
+ helper_method :instagram_connect_user_id
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ module InstagramConnect
2
+ # Moderate comments on the account's media: list, reply, hide/unhide, delete.
3
+ # Moderation calls the Graph API synchronously (fast, operator-facing) and only
4
+ # updates local state on success.
5
+ class CommentsController < ApplicationController
6
+ before_action :set_comment, only: %i[reply hide unhide destroy]
7
+
8
+ def index
9
+ per = InstagramConnect.configuration.default_per_page
10
+ @page = [ params[:page].to_i, 1 ].max
11
+ @comments = Comment.order(created_at: :desc).limit(per).offset((@page - 1) * per)
12
+ end
13
+
14
+ def reply
15
+ moderate(notice: "Reply posted.",
16
+ call: ->(client) { client.reply_comment(comment_id: @comment.comment_id, text: params[:text].to_s) },
17
+ on_success: -> { @comment.update!(replied_at: Time.current) })
18
+ end
19
+
20
+ def hide
21
+ moderate(notice: "Comment hidden.",
22
+ call: ->(client) { client.hide_comment(comment_id: @comment.comment_id, hidden: true) },
23
+ on_success: -> { @comment.update!(hidden_at: Time.current) })
24
+ end
25
+
26
+ def unhide
27
+ moderate(notice: "Comment unhidden.",
28
+ call: ->(client) { client.hide_comment(comment_id: @comment.comment_id, hidden: false) },
29
+ on_success: -> { @comment.update!(hidden_at: nil) })
30
+ end
31
+
32
+ def destroy
33
+ moderate(notice: "Comment deleted.",
34
+ call: ->(client) { client.delete_comment(comment_id: @comment.comment_id) },
35
+ on_success: -> { @comment.destroy! })
36
+ end
37
+
38
+ private
39
+
40
+ def set_comment
41
+ @comment = Comment.find(params[:id])
42
+ end
43
+
44
+ def moderate(notice:, call:, on_success:)
45
+ result = call.call(client_for(@comment))
46
+ if result.success?
47
+ on_success.call
48
+ redirect_to comments_path, notice: notice
49
+ else
50
+ redirect_to comments_path, alert: result.error_message
51
+ end
52
+ end
53
+
54
+ def client_for(comment)
55
+ account = comment.account
56
+ Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,17 @@
1
+ module InstagramConnect
2
+ # The DM inbox: a paginated list of threads and a single thread view.
3
+ class ConversationsController < ApplicationController
4
+ def index
5
+ per = InstagramConnect.configuration.default_per_page
6
+ @page = [ params[:page].to_i, 1 ].max
7
+ @conversations = Conversation.recent.limit(per).offset((@page - 1) * per)
8
+ end
9
+
10
+ def show
11
+ @conversation = Conversation.find(params[:id])
12
+ @messages = @conversation.messages.chronological
13
+ @conversation.update!(unread_count: 0) if @conversation.unread_count.positive?
14
+ @window = MessagingWindow.new(last_inbound_at: @conversation.last_inbound_at)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ module InstagramConnect
2
+ # Composes an outbound reply in a thread and hands it to the send job.
3
+ class MessagesController < ApplicationController
4
+ def create
5
+ conversation = Conversation.find(params[:conversation_id])
6
+
7
+ if params[:body].to_s.strip.empty?
8
+ return redirect_to conversation_path(conversation), alert: "Message can't be blank."
9
+ end
10
+
11
+ message = conversation.messages.create!(
12
+ direction: "outbound", status: "pending", kind: "dm", source: "manual",
13
+ body: params[:body], sent_by_id: instagram_connect_user_id
14
+ )
15
+ SendMessageJob.perform_later(message.id)
16
+ redirect_to conversation_path(conversation), notice: "Reply queued."
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ module InstagramConnect
2
+ # Drives the "Connect Instagram" OAuth flow: redirect to Meta's login dialog,
3
+ # then exchange the returned code for a stored Account.
4
+ class OauthController < ApplicationController
5
+ def start
6
+ state = SecureRandom.urlsafe_base64(16)
7
+ session[:instagram_connect_oauth_state] = state
8
+ url = Auth.for(InstagramConnect.configuration).authorize_url(redirect_uri: callback_url, state: state)
9
+ redirect_to url, allow_other_host: true
10
+ end
11
+
12
+ def callback
13
+ return redirect_with("Instagram authorization was declined: #{params[:error]}") if params[:error].present?
14
+ return redirect_with("OAuth state mismatch — please try connecting again.") if invalid_state?
15
+
16
+ Connect.call(code: params[:code], redirect_uri: callback_url, connected_by_id: instagram_connect_user_id)
17
+ session.delete(:instagram_connect_oauth_state)
18
+ redirect_to after_connect_path, notice: "Instagram account connected."
19
+ rescue InstagramConnect::Error => e
20
+ redirect_with("Could not connect Instagram: #{e.message}")
21
+ end
22
+
23
+ private
24
+
25
+ def invalid_state?
26
+ params[:state].to_s != session[:instagram_connect_oauth_state].to_s
27
+ end
28
+
29
+ def redirect_with(alert)
30
+ redirect_to after_connect_path, alert: alert
31
+ end
32
+
33
+ # Meta enforces an exact-match redirect URI. Hosts can pin it via
34
+ # config.redirect_uri; otherwise the engine's own callback URL is used.
35
+ def callback_url
36
+ InstagramConnect.configuration.redirect_uri.presence || url_for(action: :callback, only_path: false)
37
+ end
38
+
39
+ def after_connect_path
40
+ InstagramConnect.configuration.after_connect_redirect
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ module InstagramConnect
2
+ # Publishing: list the account's own media and publish a new image post
3
+ # (container -> publish). Reels/video/carousel are a documented follow-on.
4
+ class PostsController < ApplicationController
5
+ before_action :require_account
6
+
7
+ def index
8
+ client = client_for(@account)
9
+ @media = Array(client.list_media.data["data"])
10
+ @quota_usage = client.publishing_limit.data.dig("data", 0, "quota_usage")
11
+ end
12
+
13
+ def new
14
+ end
15
+
16
+ def create
17
+ client = client_for(@account)
18
+ container = client.create_media_container(image_url: params[:image_url], caption: params[:caption])
19
+ return redirect_to new_post_path, alert: container.error_message if container.failure?
20
+
21
+ published = client.publish_media(creation_id: container.id)
22
+ if published.success?
23
+ redirect_to posts_path, notice: "Published to Instagram."
24
+ else
25
+ redirect_to new_post_path, alert: published.error_message
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def require_account
32
+ @account = Account.active.first
33
+ redirect_to conversations_path, alert: "Connect an Instagram account first." if @account.nil?
34
+ end
35
+
36
+ def client_for(account)
37
+ Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ module InstagramConnect
2
+ # Receives Meta webhooks. Inherits ActionController::Base directly (not the
3
+ # engine ApplicationController) so it bypasses the host's session auth and
4
+ # CSRF, and instead authenticates the GET handshake by verify_token and the
5
+ # POST body by HMAC signature. ACKs fast and hands persistence to a job.
6
+ class WebhooksController < ActionController::Base
7
+ skip_forgery_protection
8
+
9
+ # Rails 8 ships `rate_limit`; on 7.1 it's absent, so guard it. The false
10
+ # branch can't execute under a Rails 8 test run.
11
+ # simplecov:disable
12
+ if respond_to?(:rate_limit)
13
+ rate_limit to: 300, within: 1.minute, by: -> { request.remote_ip }, name: "instagram_connect_webhook"
14
+ end
15
+ # simplecov:enable
16
+
17
+ # GET — Meta's subscription verification handshake.
18
+ def verify
19
+ if valid_verification?
20
+ render plain: params["hub.challenge"].to_s
21
+ else
22
+ head :forbidden
23
+ end
24
+ end
25
+
26
+ # POST — a batch of events. Verify the signature, then enqueue ingestion.
27
+ def receive
28
+ unless SignatureVerifier.valid?(raw_body: request.raw_post, signature: request.headers["X-Hub-Signature-256"])
29
+ return head :unauthorized
30
+ end
31
+
32
+ payload = parse_json(request.raw_post)
33
+ return head :bad_request if payload.nil?
34
+
35
+ IngestJob.perform_later(payload)
36
+ head :ok
37
+ end
38
+
39
+ private
40
+
41
+ def valid_verification?
42
+ token = InstagramConnect.configuration.verify_token.to_s
43
+ return false if token.empty?
44
+
45
+ params["hub.mode"] == "subscribe" && params["hub.verify_token"].to_s == token
46
+ end
47
+
48
+ def parse_json(raw)
49
+ JSON.parse(raw.to_s)
50
+ rescue JSON::ParserError
51
+ nil
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,4 @@
1
+ module InstagramConnect
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ module InstagramConnect
2
+ # Persists a verified webhook payload off the request path so the controller
3
+ # can ACK Meta immediately.
4
+ class IngestJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(payload)
8
+ Ingest.call(payload: payload)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ module InstagramConnect
2
+ # Refreshes access tokens for active accounts before they expire. Schedule it
3
+ # daily (e.g. via Solid Queue recurring tasks). One account's failure never
4
+ # aborts the batch. Accounts on a non-expiring path (Facebook-Login) simply
5
+ # keep their token — refresh_token there is a no-op.
6
+ class RefreshTokensJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ DEFAULT_WINDOW = 7 * 24 * 60 * 60 # seconds
10
+
11
+ def perform(within: DEFAULT_WINDOW)
12
+ threshold = Time.current + within
13
+ Account.active.token_expiring_before(threshold).find_each do |account|
14
+ account.refresh_access_token!
15
+ rescue StandardError => e
16
+ InstagramConnect.configuration.logger&.error(
17
+ "[instagram_connect] token refresh failed for account #{account.id}: #{e.message}"
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ module InstagramConnect
2
+ # Sends one pending outbound Message via the Graph API, exactly once. Claims
3
+ # the row (pending -> sending) with an atomic UPDATE so a duplicate enqueue
4
+ # can't double-send, then enforces Meta's messaging window — auto-applying the
5
+ # HUMAN_AGENT tag once the standard 24h window has passed.
6
+ class SendMessageJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ def perform(message_id)
10
+ claimed = Message.where(id: message_id, status: "pending")
11
+ .update_all(status: "sending", updated_at: Time.current)
12
+ return unless claimed == 1
13
+
14
+ message = Message.find(message_id)
15
+ tag = MessagingWindow.new(last_inbound_at: message.conversation.last_inbound_at).send_tag
16
+
17
+ if tag == :blocked
18
+ return fail_message(message, "outside_messaging_window",
19
+ "The reply window has closed; the customer must message again.")
20
+ end
21
+
22
+ deliver(message, tag)
23
+ end
24
+
25
+ private
26
+
27
+ def deliver(message, tag)
28
+ conversation = message.conversation
29
+ account = conversation.account
30
+ client = Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
31
+ result = client.send_text(recipient_id: conversation.igsid, text: message.body.to_s, tag: tag)
32
+
33
+ if result.success?
34
+ message.update!(status: "sent", ig_message_id: result.id, message_tag: tag)
35
+ else
36
+ fail_message(message, result.error_code&.to_s, result.error_message)
37
+ end
38
+ end
39
+
40
+ def fail_message(message, reason, error)
41
+ message.update!(status: "failed", failure_reason: reason, error_message: error)
42
+ end
43
+ end
44
+ end