instagram_connect 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +36 -0
- data/CHANGELOG.md +89 -0
- data/Gemfile +4 -0
- data/README.md +328 -73
- data/app/controllers/instagram_connect/comments_controller.rb +1 -1
- data/app/controllers/instagram_connect/posts_controller.rb +1 -1
- data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
- data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
- data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
- data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
- data/app/jobs/instagram_connect/send_message_job.rb +44 -16
- data/app/models/instagram_connect/account.rb +27 -1
- data/app/models/instagram_connect/api_budget.rb +22 -0
- data/app/models/instagram_connect/conversation.rb +99 -11
- data/app/models/instagram_connect/inbound_message.rb +11 -3
- data/app/models/instagram_connect/insight_snapshot.rb +43 -0
- data/app/models/instagram_connect/media_item.rb +38 -0
- data/app/models/instagram_connect/mention.rb +37 -0
- data/app/models/instagram_connect/message.rb +34 -2
- data/app/models/instagram_connect/message_attachment.rb +53 -0
- data/app/models/instagram_connect/message_reaction.rb +33 -0
- data/app/models/instagram_connect/webhook_event.rb +67 -0
- data/db/migrate/20260715072548_create_instagram_connect_accounts.rb +2 -2
- data/db/migrate/20260715072549_create_instagram_connect_conversations.rb +2 -2
- data/db/migrate/20260715072550_create_instagram_connect_messages.rb +3 -3
- data/db/migrate/20260715072551_create_instagram_connect_inbound_messages.rb +2 -2
- data/db/migrate/20260715072552_create_instagram_connect_comments.rb +3 -3
- data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
- data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
- data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
- data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
- data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
- data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
- data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
- data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
- data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
- data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
- data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
- data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
- data/docs/CONFIGURATION.md +107 -0
- data/lib/instagram_connect/client.rb +109 -3
- data/lib/instagram_connect/configuration.rb +10 -0
- data/lib/instagram_connect/engine.rb +1 -0
- data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
- data/lib/instagram_connect/ingest/envelope.rb +31 -0
- data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
- data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
- data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
- data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
- data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
- data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
- data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
- data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
- data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
- data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
- data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
- data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
- data/lib/instagram_connect/ingest/registry.rb +93 -0
- data/lib/instagram_connect/ingest/summary.rb +75 -0
- data/lib/instagram_connect/ingest.rb +27 -105
- data/lib/instagram_connect/media/attaching.rb +57 -0
- data/lib/instagram_connect/media/limits.rb +46 -0
- data/lib/instagram_connect/rate_limiter.rb +88 -0
- data/lib/instagram_connect/result.rb +26 -6
- data/lib/instagram_connect/text_splitter.rb +51 -0
- data/lib/instagram_connect/usage.rb +68 -0
- data/lib/instagram_connect/version.rb +1 -1
- data/lib/instagram_connect.rb +23 -0
- metadata +60 -2
- data/MIT-LICENSE +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfea76e8f86d1da429ba916fdf03124bb3eb0a4f0cc8d50bdeb5db838282026a
|
|
4
|
+
data.tar.gz: b55274a8583dcad12bedca7025517fbfcea36865479f28c520c7c103e8e4d49a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9d2f5d664868d9444883e603684bf22e65880d8e75ef65d6014c14a7a6065cdf5d199b1d77c6c3c2f3178c7c804ebc921cdf08966d6c68b2ee18686a1c3a3f4
|
|
7
|
+
data.tar.gz: d21363ec67aa080df5ff76747cf6f97db0b81963fe66716e7df44e44456dab3cf5b528e502f43c441a967a8d029459d2bb1dcef343b215979524bcc9c068bbbe
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -39,3 +39,39 @@ jobs:
|
|
|
39
39
|
|
|
40
40
|
- name: Run tests
|
|
41
41
|
run: bundle exec rspec --format progress
|
|
42
|
+
|
|
43
|
+
# The gem ships its own migrations into whatever database the host runs, and
|
|
44
|
+
# most hosts run Postgres. The SQLite job above is the fast default so a
|
|
45
|
+
# contributor needs no services; this one is what stops adapter-specific DDL
|
|
46
|
+
# in db/migrate from reaching an adopter's deploy unnoticed.
|
|
47
|
+
test-postgres:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
services:
|
|
50
|
+
postgres:
|
|
51
|
+
image: postgres:16
|
|
52
|
+
env:
|
|
53
|
+
POSTGRES_PASSWORD: postgres
|
|
54
|
+
ports:
|
|
55
|
+
- 5432:5432
|
|
56
|
+
options: >-
|
|
57
|
+
--health-cmd pg_isready
|
|
58
|
+
--health-interval 10s
|
|
59
|
+
--health-timeout 5s
|
|
60
|
+
--health-retries 5
|
|
61
|
+
steps:
|
|
62
|
+
- name: Checkout code
|
|
63
|
+
uses: actions/checkout@v6
|
|
64
|
+
|
|
65
|
+
- name: Set up Ruby
|
|
66
|
+
uses: ruby/setup-ruby@v1
|
|
67
|
+
with:
|
|
68
|
+
ruby-version: "3.3"
|
|
69
|
+
bundler-cache: true
|
|
70
|
+
|
|
71
|
+
- name: Run tests against Postgres
|
|
72
|
+
env:
|
|
73
|
+
PGPASSWORD: postgres
|
|
74
|
+
INSTAGRAM_CONNECT_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/instagram_connect_test
|
|
75
|
+
run: |
|
|
76
|
+
createdb -h localhost -U postgres instagram_connect_test
|
|
77
|
+
bundle exec rspec --format progress
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,95 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.3.0] - 2026-07-26
|
|
10
|
+
|
|
11
|
+
Conversations and engagement. The gem parsed 2 of the 15 webhook fields Meta can
|
|
12
|
+
send on the `instagram` object; it now parses 12 and durably banks the rest.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Webhook event ledger.** Every event is written to
|
|
16
|
+
`instagram_connect_webhook_events` verbatim before anything parses it. Meta
|
|
17
|
+
neither stores webhook notifications nor re-delivers them, so a parse bug used
|
|
18
|
+
to be permanent data loss and is now a re-runnable row. `ReplayEventsJob`
|
|
19
|
+
re-runs banked events once a handler exists, which also means a field can be
|
|
20
|
+
subscribed at Meta before the gem can parse it.
|
|
21
|
+
- **Handlers** for `message_reactions`, `messaging_seen`, `messaging_referral`,
|
|
22
|
+
`messaging_optins`, `messaging_handover`, `mentions` and `story_insights`,
|
|
23
|
+
alongside the existing messages, echoes, postbacks and comments.
|
|
24
|
+
- **Inbound media.** Attachments get a row each and one fetch job apiece; bytes
|
|
25
|
+
are copied into the host's storage with a magic-byte MIME sniff that overrides
|
|
26
|
+
the declared Content-Type. Requires Active Storage in the host, and no-ops
|
|
27
|
+
without it.
|
|
28
|
+
- **Outbound media** via Meta's Attachment Upload API, which moves bytes over an
|
|
29
|
+
authenticated POST rather than exposing a signed URL to a customer's file.
|
|
30
|
+
- `TextSplitter`, which splits replies over Meta's 1000-**byte** message ceiling
|
|
31
|
+
rather than letting the send be rejected whole.
|
|
32
|
+
- Sender actions, quick replies, generic templates, private replies, ice
|
|
33
|
+
breakers and the persistent menu on the client.
|
|
34
|
+
- **Per-account tokens.** `page_access_token` is now its own encrypted column;
|
|
35
|
+
`Account#client` is the only place a client is built and pins the config to
|
|
36
|
+
the account's own auth path.
|
|
37
|
+
- `AccountReadinessJob`, moved in from host applications. It swaps the OAuth
|
|
38
|
+
user token for the Page token and subscribes the Page to the webhook fields
|
|
39
|
+
the ingest registry can actually parse.
|
|
40
|
+
- **Cursor pagination** (`Client#collect`, `#each_page`) and **rate budgets**
|
|
41
|
+
(`RateLimiter`, `instagram_connect_api_budgets`), driven by Meta's own
|
|
42
|
+
`X-Business-Use-Case-Usage` header rather than by local arithmetic.
|
|
43
|
+
- `ProfileSyncJob`, filling the `username` and `display_name` columns that had
|
|
44
|
+
existed since 0.1 with nothing ever writing them.
|
|
45
|
+
- `MediaItem`, `Mention`, `InsightSnapshot`, `MessageAttachment`,
|
|
46
|
+
`MessageReaction`, `WebhookEvent` and `ApiBudget` models, with migrations.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Messages carry `sent_at`, Meta's own clock. Thread ordering used our
|
|
50
|
+
`created_at`, so a webhook batch delayed by a redeploy silently reordered a
|
|
51
|
+
conversation. Every `Conversation` writer is now monotonic.
|
|
52
|
+
- Uniqueness is scoped per account. A global unique index on `ig_message_id`
|
|
53
|
+
dropped messages outright when two connected accounts messaged each other.
|
|
54
|
+
- An attachment arriving with no URL no longer marks the message media
|
|
55
|
+
`pending`. Instagram CDN links expire and cannot be re-requested, so that left
|
|
56
|
+
a loading placeholder that would never resolve.
|
|
57
|
+
- `Ingest.call` returns a `Summary` rather than a plain Hash. It reads as the
|
|
58
|
+
old hash — `skipped` still reports duplicates plus unhandled — and adds
|
|
59
|
+
`duplicates`, `unhandled`, `failed` and `callback_errors`.
|
|
60
|
+
- Host callbacks fire outside the handler in their own rescue. The rows are
|
|
61
|
+
committed by then, so a raising hook no longer marks the event failed and
|
|
62
|
+
cause it to be replayed and duplicated.
|
|
63
|
+
|
|
64
|
+
### Deprecated
|
|
65
|
+
- `InboundMessage`. `WebhookEvent` is the dedupe claim now: keying every event
|
|
66
|
+
type on one message id is wrong once `messaging_seen` carries the mid of a
|
|
67
|
+
message already claimed. Kept in step for 0.2.x hosts; removal at 1.0.
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
- Every `Client` was built from the globally configured auth path, so a host
|
|
71
|
+
with accounts on both paths talked to the wrong Graph host — silently.
|
|
72
|
+
- `refresh_access_token!` refreshed via the global strategy rather than the
|
|
73
|
+
account's own.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
### Documentation
|
|
77
|
+
- Rewrote the README with a badge row, table of contents, a quick start, and a
|
|
78
|
+
usage section per feature (webhooks and signature verification, the messaging
|
|
79
|
+
window, comment moderation, publishing, the Graph client, background jobs, the
|
|
80
|
+
UI and theming, and the CLI).
|
|
81
|
+
- Added `docs/CONFIGURATION.md`, a full reference for every `Configuration`
|
|
82
|
+
setting with its type, default, and environment-variable fallback.
|
|
83
|
+
- Added `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1), and
|
|
84
|
+
`SECURITY.md`.
|
|
85
|
+
- Removed the duplicate `MIT-LICENSE` file, keeping `LICENSE.txt` as the single
|
|
86
|
+
license, and updated the gemspec `files` list to match.
|
|
87
|
+
|
|
88
|
+
## [0.2.1]
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
- Migrations are now idempotent (`create_table` / `add_index` with `if_not_exists: true`),
|
|
92
|
+
so `db:migrate` / `db:prepare` succeeds on a database that already has the tables —
|
|
93
|
+
e.g. an existing deploy, or one set up via `db:schema:load`. Previously a redeploy
|
|
94
|
+
could fail with `PG::DuplicateTable`.
|
|
95
|
+
|
|
7
96
|
## [0.2.0]
|
|
8
97
|
|
|
9
98
|
True plug-and-play: the gem now owns the data model and the UI, so a host adds
|
data/Gemfile
CHANGED
|
@@ -6,6 +6,10 @@ gemspec
|
|
|
6
6
|
gem "puma"
|
|
7
7
|
|
|
8
8
|
gem "sqlite3"
|
|
9
|
+
# The suite runs on SQLite by default so a contributor needs no services, and on
|
|
10
|
+
# Postgres in CI so adapter-specific DDL in db/migrate cannot reach an adopter
|
|
11
|
+
# unnoticed. See INSTAGRAM_CONNECT_TEST_DATABASE_URL in spec/rails_helper.rb.
|
|
12
|
+
gem "pg"
|
|
9
13
|
gem "rack-test"
|
|
10
14
|
gem "webmock"
|
|
11
15
|
gem "rspec-rails", "~> 8.0"
|
data/README.md
CHANGED
|
@@ -1,63 +1,116 @@
|
|
|
1
1
|
# instagram_connect
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
3
|
+
Instagram DMs, comments, and publishing for Rails, over the official Meta Graph API.
|
|
7
4
|
|
|
5
|
+
[](https://rubygems.org/gems/instagram_connect)
|
|
8
6
|
[](https://github.com/kshtzkr/instagram_connect/actions/workflows/ci.yml)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
[](LICENSE.txt)
|
|
8
|
+
[](https://www.ruby-lang.org)
|
|
9
|
+
|
|
10
|
+
`instagram_connect` is a mountable Rails engine that connects your app to an
|
|
11
|
+
Instagram professional account. You can receive and reply to DMs and comments in
|
|
12
|
+
real time through HMAC-verified webhooks, publish image posts, and manage OAuth
|
|
13
|
+
tokens. It speaks only the sanctioned Meta Graph API (Instagram Login or Facebook
|
|
14
|
+
Login) — there is no reverse-engineered automation or browser scripting involved.
|
|
15
|
+
|
|
16
|
+
That distinction matters: Instagram disables accounts that use private-API
|
|
17
|
+
automation, and an appeal rarely restores the handle, followers, or DM history.
|
|
18
|
+
Using the official API keeps the account in good standing.
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
- [Features](#features)
|
|
23
|
+
- [Requirements](#requirements)
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick start](#quick-start)
|
|
26
|
+
- [Configuration](#configuration)
|
|
27
|
+
- [Connecting an account](#connecting-an-account)
|
|
28
|
+
- [Webhooks](#webhooks)
|
|
29
|
+
- [Replying to DMs and the messaging window](#replying-to-dms-and-the-messaging-window)
|
|
30
|
+
- [Moderating comments](#moderating-comments)
|
|
31
|
+
- [Publishing](#publishing)
|
|
32
|
+
- [Using the Graph client directly](#using-the-graph-client-directly)
|
|
33
|
+
- [Background jobs](#background-jobs)
|
|
34
|
+
- [The inbox UI and theming](#the-inbox-ui-and-theming)
|
|
35
|
+
- [CLI](#cli)
|
|
36
|
+
- [Data model](#data-model)
|
|
37
|
+
- [Auth paths](#auth-paths)
|
|
38
|
+
- [Meta App Review](#meta-app-review)
|
|
39
|
+
- [Meta platform constraints](#meta-platform-constraints)
|
|
40
|
+
- [Testing](#testing)
|
|
41
|
+
- [Development](#development)
|
|
42
|
+
- [Contributing](#contributing)
|
|
43
|
+
- [Security](#security)
|
|
44
|
+
- [Versioning](#versioning)
|
|
45
|
+
- [License](#license)
|
|
17
46
|
|
|
18
47
|
## Features
|
|
19
48
|
|
|
20
|
-
-
|
|
21
|
-
Meta's 24-hour window (extended to 7 days
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
(linked Page
|
|
49
|
+
- DMs: receive inbound messages in real time through webhooks, and reply with
|
|
50
|
+
text inside Meta's 24-hour window (extended to 7 days with the human-agent tag).
|
|
51
|
+
- Comments: read, reply to, hide, unhide, and delete comments on your media.
|
|
52
|
+
- Publishing: publish image posts. Reels, video, and carousel are on the roadmap.
|
|
53
|
+
- Two auth paths: `:instagram_login` (no Facebook Page) or `:facebook_login`
|
|
54
|
+
(linked Page and durable tokens), selected by configuration. See
|
|
26
55
|
[docs/auth_paths.md](docs/auth_paths.md).
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
-
|
|
56
|
+
- A mounted UI: a DM inbox, comment moderation, and a publish screen, rendered in
|
|
57
|
+
the engine's own layout and themeable from the initializer.
|
|
58
|
+
- Encrypted tokens at rest and HMAC-verified webhooks.
|
|
30
59
|
|
|
31
60
|
## Requirements
|
|
32
61
|
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
- Ruby >= 3.1
|
|
63
|
+
- Rails >= 7.1
|
|
64
|
+
- An Active Job backend (Solid Queue, Sidekiq, and so on). Webhook ingestion,
|
|
65
|
+
outbound sends, and token refresh all run as jobs.
|
|
66
|
+
- Active Record Encryption configured (`bin/rails db:encryption:init`) for token
|
|
67
|
+
storage, or set `config.encrypt_tokens = false` if your app has no encryption.
|
|
68
|
+
- A Meta app with an Instagram **professional** account (Business or Creator) and
|
|
69
|
+
either Instagram Login or Facebook Login set up. See
|
|
70
|
+
[docs/auth_paths.md](docs/auth_paths.md).
|
|
39
71
|
|
|
40
72
|
## Installation
|
|
41
73
|
|
|
74
|
+
Add the gem:
|
|
75
|
+
|
|
42
76
|
```ruby
|
|
43
77
|
# Gemfile
|
|
44
78
|
gem "instagram_connect"
|
|
45
79
|
```
|
|
46
80
|
|
|
81
|
+
Then install and migrate:
|
|
82
|
+
|
|
47
83
|
```bash
|
|
48
84
|
bundle install
|
|
49
|
-
bin/rails g instagram_connect:install # writes the initializer
|
|
50
|
-
bin/rails db:migrate
|
|
85
|
+
bin/rails g instagram_connect:install # writes the initializer and mounts the engine
|
|
86
|
+
bin/rails db:migrate # runs the gem's migrations in place
|
|
51
87
|
```
|
|
52
88
|
|
|
53
|
-
The install generator writes `config/initializers/instagram_connect.rb` and
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
versioned with the gem.
|
|
57
|
-
|
|
89
|
+
The install generator writes `config/initializers/instagram_connect.rb` and adds
|
|
90
|
+
`mount InstagramConnect::Engine => "/instagram"` to your routes. Migrations ship
|
|
91
|
+
inside the gem and run in place, so nothing is copied into your app and every
|
|
92
|
+
adopter gets the same schema, versioned with the gem. Configuration is the only
|
|
93
|
+
thing you add.
|
|
94
|
+
|
|
95
|
+
## Quick start
|
|
96
|
+
|
|
97
|
+
1. Fill in the initializer with your Meta app credentials (see
|
|
98
|
+
[Configuration](#configuration)).
|
|
99
|
+
2. Point your Meta app's webhook at `https://<your-host>/instagram/webhooks` with
|
|
100
|
+
the same `verify_token` (see [Webhooks](#webhooks)).
|
|
101
|
+
3. Send an operator to `GET /instagram/oauth/start` to connect the account.
|
|
102
|
+
4. Open `/instagram` for the inbox.
|
|
103
|
+
|
|
104
|
+
Check that the credentials are in place before you start:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bundle exec instagram_connect doctor
|
|
108
|
+
```
|
|
58
109
|
|
|
59
110
|
## Configuration
|
|
60
111
|
|
|
112
|
+
The initializer covers the settings most apps need:
|
|
113
|
+
|
|
61
114
|
```ruby
|
|
62
115
|
# config/initializers/instagram_connect.rb
|
|
63
116
|
InstagramConnect.configure do |config|
|
|
@@ -71,7 +124,7 @@ InstagramConnect.configure do |config|
|
|
|
71
124
|
config.parent_controller = "ApplicationController"
|
|
72
125
|
config.authenticate_with = -> { authenticate_user! } # runs in controller context
|
|
73
126
|
config.current_user_id_resolver = -> { current_user&.id } # attributes replies
|
|
74
|
-
config.after_connect_redirect = "/instagram"
|
|
127
|
+
config.after_connect_redirect = "/instagram/conversations"
|
|
75
128
|
|
|
76
129
|
# Optional host hooks
|
|
77
130
|
config.on_message = ->(message) { NotifyOpsJob.perform_later(message.id) }
|
|
@@ -79,36 +132,174 @@ InstagramConnect.configure do |config|
|
|
|
79
132
|
end
|
|
80
133
|
```
|
|
81
134
|
|
|
135
|
+
Every setting, with its type, default, and environment-variable fallback, is
|
|
136
|
+
documented in [docs/CONFIGURATION.md](docs/CONFIGURATION.md). Secrets can come
|
|
137
|
+
from the environment instead of the initializer:
|
|
138
|
+
`INSTAGRAM_CONNECT_APP_ID`, `INSTAGRAM_CONNECT_APP_SECRET`,
|
|
139
|
+
`INSTAGRAM_CONNECT_VERIFY_TOKEN`, and others.
|
|
140
|
+
|
|
82
141
|
## Connecting an account
|
|
83
142
|
|
|
84
|
-
Send an operator to `GET /instagram/oauth/start`.
|
|
85
|
-
|
|
86
|
-
`
|
|
143
|
+
Send an operator to `GET /instagram/oauth/start`. The engine redirects them to
|
|
144
|
+
Meta's login dialog with a CSRF `state`, and Meta returns to
|
|
145
|
+
`GET /instagram/oauth/callback`. The callback exchanges the code for a
|
|
146
|
+
long-lived token and stores an `InstagramConnect::Account`, then redirects to
|
|
147
|
+
`config.after_connect_redirect`.
|
|
148
|
+
|
|
149
|
+
For the Facebook Login path the engine reads the Instagram business account
|
|
150
|
+
linked to the operator's Page to resolve the account identity. For the Instagram
|
|
151
|
+
Login path the identity comes back directly from the token exchange.
|
|
152
|
+
|
|
153
|
+
Meta enforces an exact-match redirect URI. If the engine's own callback URL does
|
|
154
|
+
not match what you registered, pin it with `config.redirect_uri`.
|
|
155
|
+
|
|
156
|
+
## Webhooks
|
|
157
|
+
|
|
158
|
+
Register `https://<your-host>/instagram/webhooks` as the callback URL in your
|
|
159
|
+
Meta app and set the verify token to match `config.verify_token`. Subscribe the
|
|
160
|
+
fields you need: `messages`, `messaging_postbacks`, `comments`, `mentions`. Meta
|
|
161
|
+
must reach the URL publicly, so use a staging host or a tunnel during
|
|
162
|
+
development; localhost will not receive callbacks.
|
|
163
|
+
|
|
164
|
+
Two things happen at that path:
|
|
165
|
+
|
|
166
|
+
- **Verification handshake (GET).** Meta sends `hub.mode=subscribe`, a
|
|
167
|
+
`hub.verify_token`, and a `hub.challenge`. The controller confirms the token
|
|
168
|
+
matches `config.verify_token` and echoes the challenge back, otherwise it
|
|
169
|
+
responds `403`.
|
|
170
|
+
- **Event delivery (POST).** Every delivery carries an `X-Hub-Signature-256`
|
|
171
|
+
header: the HMAC-SHA256 of the raw request body keyed by your `app_secret`.
|
|
172
|
+
`InstagramConnect::SignatureVerifier` recomputes it and compares in constant
|
|
173
|
+
time. A bad or missing signature is rejected with `401`. The webhook controller
|
|
174
|
+
inherits `ActionController::Base` directly (not your host controller), so it
|
|
175
|
+
bypasses session auth and CSRF and authenticates only by this HMAC.
|
|
176
|
+
|
|
177
|
+
A verified POST is acknowledged immediately and handed to
|
|
178
|
+
`InstagramConnect::IngestJob`, so Meta gets a fast `200`.
|
|
179
|
+
`InstagramConnect::Ingest` then walks the payload:
|
|
180
|
+
|
|
181
|
+
- **Messages** are deduped on Meta's message id through the
|
|
182
|
+
`InstagramConnect::InboundMessage` ledger (so a redelivery is not processed
|
|
183
|
+
twice), stored as `Message` rows on the right `Conversation`, and inbound
|
|
184
|
+
messages fire `config.on_message`. Echoes of your own outbound sends are
|
|
185
|
+
stored as outbound but do not fire the hook.
|
|
186
|
+
- **Comments** are upserted by comment id into `Comment` and fire
|
|
187
|
+
`config.on_comment`.
|
|
188
|
+
- **Postbacks** fire `config.on_postback` with
|
|
189
|
+
`{ account_id:, sender_id:, payload:, title: }`.
|
|
190
|
+
|
|
191
|
+
The engine also adds `access_token`, `app_secret`, `verify_token`, and
|
|
192
|
+
`hub_verify_token` to Rails' filtered parameters, so they do not appear in logs.
|
|
193
|
+
|
|
194
|
+
## Replying to DMs and the messaging window
|
|
195
|
+
|
|
196
|
+
The inbox composer creates an outbound `Message` in the `pending` state and
|
|
197
|
+
enqueues `InstagramConnect::SendMessageJob`. The job claims the row with an
|
|
198
|
+
atomic update (so a duplicate enqueue cannot double-send) and then checks Meta's
|
|
199
|
+
messaging window before it sends.
|
|
200
|
+
|
|
201
|
+
`InstagramConnect::MessagingWindow` encodes the rule. Measuring from the user's
|
|
202
|
+
last inbound message:
|
|
203
|
+
|
|
204
|
+
- within 24 hours: `:standard`, sent with no tag.
|
|
205
|
+
- 24 hours to 7 days: `:human_agent`, sent with the `HUMAN_AGENT` tag (human
|
|
206
|
+
replies only).
|
|
207
|
+
- past 7 days, or the user has never messaged: `:closed`, nothing may be sent.
|
|
208
|
+
|
|
209
|
+
When the window is closed the job marks the message `failed` with reason
|
|
210
|
+
`outside_messaging_window` rather than calling the API. You can check the state
|
|
211
|
+
yourself:
|
|
87
212
|
|
|
88
213
|
```ruby
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
214
|
+
window = InstagramConnect::MessagingWindow.new(last_inbound_at: conversation.last_inbound_at)
|
|
215
|
+
window.open? # => true / false
|
|
216
|
+
window.state # => :standard, :human_agent, or :closed
|
|
217
|
+
window.send_tag # => nil, "HUMAN_AGENT", or :blocked
|
|
93
218
|
```
|
|
94
219
|
|
|
95
|
-
##
|
|
220
|
+
## Moderating comments
|
|
221
|
+
|
|
222
|
+
The mounted `/instagram/comments` screen lists recorded comments and moderates
|
|
223
|
+
them. Moderation calls the Graph API synchronously and updates local state only
|
|
224
|
+
on success. The same operations are available on the client:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
client = InstagramConnect::Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
|
|
228
|
+
client.reply_comment(comment_id: id, text: "Thanks!")
|
|
229
|
+
client.hide_comment(comment_id: id, hidden: true)
|
|
230
|
+
client.delete_comment(comment_id: id)
|
|
231
|
+
client.list_comments(media_id: media_id)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Publishing
|
|
235
|
+
|
|
236
|
+
Publishing an image is a two-step Graph flow: create a media container from a
|
|
237
|
+
public HTTPS image URL, then publish it. The mounted `/instagram/posts` screen
|
|
238
|
+
does this, and the client exposes each step:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
client = InstagramConnect::Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
|
|
242
|
+
|
|
243
|
+
container = client.create_media_container(image_url: "https://example.com/photo.jpg", caption: "Hello")
|
|
244
|
+
published = client.publish_media(creation_id: container.id) if container.success?
|
|
245
|
+
|
|
246
|
+
client.publishing_limit # remaining quota in the rolling 24-hour window
|
|
247
|
+
```
|
|
96
248
|
|
|
97
|
-
|
|
98
|
-
same `verify_token` you configured. Subscribe the fields you need: `messages`,
|
|
99
|
-
`messaging_postbacks`, `comments`, `mentions`. The engine verifies the
|
|
100
|
-
`X-Hub-Signature-256` HMAC on every delivery and ingests off the request path.
|
|
249
|
+
## Using the Graph client directly
|
|
101
250
|
|
|
102
|
-
|
|
251
|
+
`InstagramConnect::Client` is a thin wrapper over the Graph API, bound to one
|
|
252
|
+
account's access token. Every call returns an `InstagramConnect::Result` rather
|
|
253
|
+
than raising on an API error, so callers branch on `success?`:
|
|
103
254
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
255
|
+
```ruby
|
|
256
|
+
client = InstagramConnect::Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
|
|
257
|
+
|
|
258
|
+
result = client.send_text(recipient_id: igsid, text: "Hi there")
|
|
259
|
+
if result.success?
|
|
260
|
+
result.id # Meta's message id
|
|
261
|
+
else
|
|
262
|
+
result.error_message # human-readable message from Meta
|
|
263
|
+
result.error_code # Meta's application-level code
|
|
264
|
+
result.retry_after # rate-limit hint, when present
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Other client methods include `send_media`, `send_reaction`, `private_reply`,
|
|
269
|
+
`list_media`, `media_insights`, `profile`, and `list_pages`. Transport and
|
|
270
|
+
programming errors still raise; only API-level failures come back as a failed
|
|
271
|
+
`Result`.
|
|
272
|
+
|
|
273
|
+
## Background jobs
|
|
274
|
+
|
|
275
|
+
Three jobs do the asynchronous work. All use Active Job, so they run on whatever
|
|
276
|
+
backend your app configures.
|
|
277
|
+
|
|
278
|
+
- `InstagramConnect::IngestJob` — persists a verified webhook payload off the
|
|
279
|
+
request path.
|
|
280
|
+
- `InstagramConnect::SendMessageJob` — sends one pending outbound message exactly
|
|
281
|
+
once, enforcing the messaging window.
|
|
282
|
+
- `InstagramConnect::RefreshTokensJob` — refreshes tokens for active accounts
|
|
283
|
+
before they expire. One account's failure does not abort the batch, and
|
|
284
|
+
accounts on the Facebook Login path (non-expiring tokens) are a no-op. Schedule
|
|
285
|
+
it daily:
|
|
286
|
+
|
|
287
|
+
```yaml
|
|
288
|
+
# config/recurring.yml (Solid Queue), or your scheduler of choice
|
|
289
|
+
instagram_connect_token_refresh:
|
|
290
|
+
class: InstagramConnect::RefreshTokensJob
|
|
291
|
+
schedule: every day at 3am
|
|
292
|
+
```
|
|
107
293
|
|
|
108
|
-
|
|
294
|
+
## The inbox UI and theming
|
|
109
295
|
|
|
110
|
-
|
|
111
|
-
|
|
296
|
+
Mounted at `/instagram`: a DM inbox with a window-aware reply composer, comment
|
|
297
|
+
moderation, and an image publish screen. By default the engine renders in its own
|
|
298
|
+
bundled layout and stylesheet (`inherit_host_layout` is `false`), like an admin
|
|
299
|
+
engine, so there is nothing to build in your app.
|
|
300
|
+
|
|
301
|
+
Tint the whole UI from the initializer. Any subset of theme keys is merged over
|
|
302
|
+
the defaults, with no CSS or view files in your app:
|
|
112
303
|
|
|
113
304
|
```ruby
|
|
114
305
|
config.theme = {
|
|
@@ -118,48 +309,112 @@ config.theme = {
|
|
|
118
309
|
}
|
|
119
310
|
```
|
|
120
311
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
312
|
+
The full list of theme keys and their defaults is in
|
|
313
|
+
[docs/CONFIGURATION.md](docs/CONFIGURATION.md#theme).
|
|
314
|
+
|
|
315
|
+
For deeper control, render inside your own layout with
|
|
316
|
+
`config.inherit_host_layout = true` and add `<%= instagram_connect_styles %>` to
|
|
317
|
+
your `<head>`, or copy the views and controllers into your app to override them:
|
|
124
318
|
|
|
125
319
|
```bash
|
|
126
320
|
bin/rails g instagram_connect:views # copy views into app/views/instagram_connect
|
|
127
|
-
bin/rails g instagram_connect:controllers # copy controllers to override
|
|
321
|
+
bin/rails g instagram_connect:controllers # copy controllers to override the engine's
|
|
128
322
|
```
|
|
129
323
|
|
|
130
324
|
## CLI
|
|
131
325
|
|
|
326
|
+
The gem installs an `instagram_connect` executable with a `doctor` command that
|
|
327
|
+
checks the configuration:
|
|
328
|
+
|
|
132
329
|
```bash
|
|
133
|
-
bundle exec instagram_connect doctor
|
|
330
|
+
bundle exec instagram_connect doctor
|
|
134
331
|
```
|
|
135
332
|
|
|
333
|
+
It reports `OK` or `MISSING` for each of: a valid `auth_path`, `app_id`,
|
|
334
|
+
`app_secret`, and `verify_token`.
|
|
335
|
+
|
|
336
|
+
## Data model
|
|
337
|
+
|
|
338
|
+
The engine owns these tables (all prefixed `instagram_connect_`):
|
|
339
|
+
|
|
340
|
+
- `Account` — a connected Instagram professional account, holding the encrypted
|
|
341
|
+
access token and the identity the client sends as.
|
|
342
|
+
- `Conversation` — one DM thread between an account and an Instagram user
|
|
343
|
+
(`igsid`), with a denormalized unread count and last-message summary.
|
|
344
|
+
- `Message` — one bubble in a thread, inbound or outbound, with direction,
|
|
345
|
+
status, kind, and source.
|
|
346
|
+
- `InboundMessage` — a dedupe ledger keyed by Meta's message id.
|
|
347
|
+
- `Comment` — a comment on the account's media, with moderation state.
|
|
348
|
+
|
|
349
|
+
## Auth paths
|
|
350
|
+
|
|
351
|
+
The gem supports both of Meta's integration paths, selected by
|
|
352
|
+
`config.auth_path`. They differ in the Graph host, whether a Facebook Page is
|
|
353
|
+
required, the OAuth scopes, and token lifetime.
|
|
354
|
+
[docs/auth_paths.md](docs/auth_paths.md) covers the trade-offs and how to add a
|
|
355
|
+
new path.
|
|
356
|
+
|
|
136
357
|
## Meta App Review
|
|
137
358
|
|
|
138
|
-
Messaging real customers requires Advanced Access, which requires
|
|
139
|
-
|
|
140
|
-
testers on your Meta app. The
|
|
141
|
-
policy, screencasts, common rejection reasons — is in
|
|
359
|
+
Messaging real customers requires Advanced Access, which requires App Review and
|
|
360
|
+
Business Verification. Until you are approved, you can only message accounts you
|
|
361
|
+
add as testers on your Meta app. The checklist — permissions per auth path,
|
|
362
|
+
privacy policy, screencasts, and common rejection reasons — is in
|
|
142
363
|
[docs/app_review_guide.md](docs/app_review_guide.md).
|
|
143
364
|
|
|
144
|
-
##
|
|
365
|
+
## Meta platform constraints
|
|
145
366
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
-
|
|
150
|
-
|
|
367
|
+
These are Meta's rules, enforced by the platform rather than the gem, but worth
|
|
368
|
+
knowing up front:
|
|
369
|
+
|
|
370
|
+
- 24-hour window: you can only reply within 24 hours of the customer's last
|
|
371
|
+
message (7 days with the human-agent tag). The gem enforces this and blocks
|
|
372
|
+
sends that would violate it.
|
|
373
|
+
- No cold DMs: you cannot message someone who has never messaged you.
|
|
374
|
+
- Publishing: a rolling limit of 100 API-published posts per 24 hours, and media
|
|
375
|
+
must be served from a public HTTPS URL.
|
|
151
376
|
|
|
152
377
|
## Testing
|
|
153
378
|
|
|
379
|
+
The suite boots a small in-memory dummy Rails app that mounts the engine, runs
|
|
380
|
+
against SQLite in memory, and stubs the Graph API with WebMock, so no database or
|
|
381
|
+
network access is needed:
|
|
382
|
+
|
|
154
383
|
```bash
|
|
155
|
-
bundle exec rspec
|
|
384
|
+
bundle exec rspec
|
|
156
385
|
bundle exec rubocop
|
|
157
386
|
```
|
|
158
387
|
|
|
159
|
-
|
|
388
|
+
Coverage is held to 100% line and branch on the gem's business-logic files.
|
|
389
|
+
|
|
390
|
+
## Development
|
|
391
|
+
|
|
392
|
+
```bash
|
|
393
|
+
git clone https://github.com/kshtzkr/instagram_connect.git
|
|
394
|
+
cd instagram_connect
|
|
395
|
+
bundle install
|
|
396
|
+
bundle exec rspec
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch and pull-request flow.
|
|
400
|
+
|
|
401
|
+
## Contributing
|
|
402
|
+
|
|
403
|
+
Bug reports and pull requests are welcome. Please read
|
|
404
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) and the
|
|
405
|
+
[Code of Conduct](CODE_OF_CONDUCT.md). Contributions must pass CI (RSpec and
|
|
406
|
+
RuboCop) and keep coverage at 100%.
|
|
407
|
+
|
|
408
|
+
## Security
|
|
409
|
+
|
|
410
|
+
Report vulnerabilities privately per [SECURITY.md](SECURITY.md). Please do not
|
|
411
|
+
open a public issue for a security problem.
|
|
412
|
+
|
|
413
|
+
## Versioning
|
|
160
414
|
|
|
161
|
-
|
|
162
|
-
|
|
415
|
+
This project follows [Semantic Versioning](https://semver.org/). Notable changes
|
|
416
|
+
are recorded in [CHANGELOG.md](CHANGELOG.md), and releases are listed on the
|
|
417
|
+
[GitHub releases page](https://github.com/kshtzkr/instagram_connect/releases).
|
|
163
418
|
|
|
164
419
|
## License
|
|
165
420
|
|