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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +41 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +145 -0
- data/Rakefile +7 -0
- data/app/controllers/instagram_connect/application_controller.rb +30 -0
- data/app/controllers/instagram_connect/comments_controller.rb +59 -0
- data/app/controllers/instagram_connect/conversations_controller.rb +17 -0
- data/app/controllers/instagram_connect/messages_controller.rb +19 -0
- data/app/controllers/instagram_connect/oauth_controller.rb +43 -0
- data/app/controllers/instagram_connect/posts_controller.rb +40 -0
- data/app/controllers/instagram_connect/webhooks_controller.rb +54 -0
- data/app/jobs/instagram_connect/application_job.rb +4 -0
- data/app/jobs/instagram_connect/ingest_job.rb +11 -0
- data/app/jobs/instagram_connect/refresh_tokens_job.rb +22 -0
- data/app/jobs/instagram_connect/send_message_job.rb +44 -0
- data/app/models/instagram_connect/account.rb +36 -0
- data/app/models/instagram_connect/application_record.rb +5 -0
- data/app/models/instagram_connect/comment.rb +26 -0
- data/app/models/instagram_connect/conversation.rb +43 -0
- data/app/models/instagram_connect/inbound_message.rb +15 -0
- data/app/models/instagram_connect/message.rb +37 -0
- data/app/views/instagram_connect/comments/_comment.html.erb +19 -0
- data/app/views/instagram_connect/comments/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_composer.html.erb +15 -0
- data/app/views/instagram_connect/conversations/_conversation.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_message.html.erb +6 -0
- data/app/views/instagram_connect/conversations/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/show.html.erb +12 -0
- data/app/views/instagram_connect/posts/index.html.erb +23 -0
- data/app/views/instagram_connect/posts/new.html.erb +17 -0
- data/app/views/layouts/instagram_connect/application.html.erb +19 -0
- data/bin/instagram_connect +6 -0
- data/config/routes.rb +28 -0
- data/docs/app_review_guide.md +57 -0
- data/docs/auth_paths.md +42 -0
- data/docs/roadmap.md +27 -0
- data/lib/generators/instagram_connect/controllers_generator.rb +16 -0
- data/lib/generators/instagram_connect/install/install_generator.rb +40 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt +17 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt +11 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt +25 -0
- data/lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt +35 -0
- data/lib/generators/instagram_connect/views_generator.rb +16 -0
- data/lib/instagram_connect/auth/facebook_login.rb +68 -0
- data/lib/instagram_connect/auth/instagram_login.rb +73 -0
- data/lib/instagram_connect/auth/strategy.rb +70 -0
- data/lib/instagram_connect/auth.rb +17 -0
- data/lib/instagram_connect/cli.rb +18 -0
- data/lib/instagram_connect/client.rb +160 -0
- data/lib/instagram_connect/configuration.rb +66 -0
- data/lib/instagram_connect/connect.rb +45 -0
- data/lib/instagram_connect/doctor.rb +22 -0
- data/lib/instagram_connect/engine.rb +28 -0
- data/lib/instagram_connect/errors.rb +25 -0
- data/lib/instagram_connect/ingest.rb +112 -0
- data/lib/instagram_connect/messaging_window.rb +42 -0
- data/lib/instagram_connect/railtie.rb +19 -0
- data/lib/instagram_connect/result.rb +34 -0
- data/lib/instagram_connect/signature_verifier.rb +23 -0
- data/lib/instagram_connect/version.rb +3 -0
- data/lib/instagram_connect.rb +53 -0
- 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
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
|
+
[](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,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,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
|