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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +36 -0
  3. data/CHANGELOG.md +89 -0
  4. data/Gemfile +4 -0
  5. data/README.md +328 -73
  6. data/app/controllers/instagram_connect/comments_controller.rb +1 -1
  7. data/app/controllers/instagram_connect/posts_controller.rb +1 -1
  8. data/app/jobs/instagram_connect/account_readiness_job.rb +148 -0
  9. data/app/jobs/instagram_connect/fetch_media_job.rb +110 -0
  10. data/app/jobs/instagram_connect/profile_sync_job.rb +78 -0
  11. data/app/jobs/instagram_connect/replay_events_job.rb +57 -0
  12. data/app/jobs/instagram_connect/send_message_job.rb +44 -16
  13. data/app/models/instagram_connect/account.rb +27 -1
  14. data/app/models/instagram_connect/api_budget.rb +22 -0
  15. data/app/models/instagram_connect/conversation.rb +99 -11
  16. data/app/models/instagram_connect/inbound_message.rb +11 -3
  17. data/app/models/instagram_connect/insight_snapshot.rb +43 -0
  18. data/app/models/instagram_connect/media_item.rb +38 -0
  19. data/app/models/instagram_connect/mention.rb +37 -0
  20. data/app/models/instagram_connect/message.rb +34 -2
  21. data/app/models/instagram_connect/message_attachment.rb +53 -0
  22. data/app/models/instagram_connect/message_reaction.rb +33 -0
  23. data/app/models/instagram_connect/webhook_event.rb +67 -0
  24. data/db/migrate/20260715072548_create_instagram_connect_accounts.rb +2 -2
  25. data/db/migrate/20260715072549_create_instagram_connect_conversations.rb +2 -2
  26. data/db/migrate/20260715072550_create_instagram_connect_messages.rb +3 -3
  27. data/db/migrate/20260715072551_create_instagram_connect_inbound_messages.rb +2 -2
  28. data/db/migrate/20260715072552_create_instagram_connect_comments.rb +3 -3
  29. data/db/migrate/20260725194732_create_instagram_connect_webhook_events.rb +52 -0
  30. data/db/migrate/20260725200216_add_messaging_event_fields.rb +89 -0
  31. data/db/migrate/20260725200250_create_instagram_connect_message_reactions.rb +30 -0
  32. data/db/migrate/20260725201320_create_instagram_connect_message_attachments.rb +33 -0
  33. data/db/migrate/20260725202616_add_outbound_send_fields.rb +27 -0
  34. data/db/migrate/20260725203213_create_instagram_connect_media.rb +30 -0
  35. data/db/migrate/20260725203214_create_instagram_connect_mentions.rb +30 -0
  36. data/db/migrate/20260725203215_create_instagram_connect_insight_snapshots.rb +45 -0
  37. data/db/migrate/20260725203306_add_comment_moderation_fields.rb +29 -0
  38. data/db/migrate/20260725204022_add_per_account_token_fields.rb +76 -0
  39. data/db/migrate/20260725204815_create_instagram_connect_api_budgets.rb +29 -0
  40. data/db/migrate/20260725205417_add_conversation_profile_fields.rb +21 -0
  41. data/docs/CONFIGURATION.md +107 -0
  42. data/lib/instagram_connect/client.rb +109 -3
  43. data/lib/instagram_connect/configuration.rb +10 -0
  44. data/lib/instagram_connect/engine.rb +1 -0
  45. data/lib/instagram_connect/ingest/dispatcher.rb +151 -0
  46. data/lib/instagram_connect/ingest/envelope.rb +31 -0
  47. data/lib/instagram_connect/ingest/handlers/base.rb +55 -0
  48. data/lib/instagram_connect/ingest/handlers/comments.rb +59 -0
  49. data/lib/instagram_connect/ingest/handlers/mentions.rb +48 -0
  50. data/lib/instagram_connect/ingest/handlers/message_reactions.rb +91 -0
  51. data/lib/instagram_connect/ingest/handlers/messages.rb +135 -0
  52. data/lib/instagram_connect/ingest/handlers/messaging_handover.rb +53 -0
  53. data/lib/instagram_connect/ingest/handlers/messaging_optins.rb +37 -0
  54. data/lib/instagram_connect/ingest/handlers/messaging_postbacks.rb +24 -0
  55. data/lib/instagram_connect/ingest/handlers/messaging_referral.rb +42 -0
  56. data/lib/instagram_connect/ingest/handlers/messaging_seen.rb +37 -0
  57. data/lib/instagram_connect/ingest/handlers/story_insights.rb +62 -0
  58. data/lib/instagram_connect/ingest/handlers/unknown.rb +19 -0
  59. data/lib/instagram_connect/ingest/registry.rb +93 -0
  60. data/lib/instagram_connect/ingest/summary.rb +75 -0
  61. data/lib/instagram_connect/ingest.rb +27 -105
  62. data/lib/instagram_connect/media/attaching.rb +57 -0
  63. data/lib/instagram_connect/media/limits.rb +46 -0
  64. data/lib/instagram_connect/rate_limiter.rb +88 -0
  65. data/lib/instagram_connect/result.rb +26 -6
  66. data/lib/instagram_connect/text_splitter.rb +51 -0
  67. data/lib/instagram_connect/usage.rb +68 -0
  68. data/lib/instagram_connect/version.rb +1 -1
  69. data/lib/instagram_connect.rb +23 -0
  70. metadata +60 -2
  71. data/MIT-LICENSE +0 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: afc5d08b4207b0268104bbf7a4955406b26298e4da4406bd5034a93b8a43982f
4
- data.tar.gz: 4d436bf9a398fc745f4a594b1346cd8315a949391213f83c45cc52c07694e993
3
+ metadata.gz: bfea76e8f86d1da429ba916fdf03124bb3eb0a4f0cc8d50bdeb5db838282026a
4
+ data.tar.gz: b55274a8583dcad12bedca7025517fbfcea36865479f28c520c7c103e8e4d49a
5
5
  SHA512:
6
- metadata.gz: 3d4894411aa0ad5924a8d4d163215a880e8344aa3306a814400cd73e2ad717d5bca7f4a4284c4baa81162af98cabc987983c4715c3eaab8b0fb16ba9a69321ec
7
- data.tar.gz: 9ce313d2dd27ce96e913da61d46eb9f4e87bcbac7681fcd6963447759286b6419a7453d50821d9909fe570eca0fe98b5620fc709ead569e4f8c88f3752b635c9
6
+ metadata.gz: b9d2f5d664868d9444883e603684bf22e65880d8e75ef65d6014c14a7a6065cdf5d199b1d77c6c3c2f3178c7c804ebc921cdf08966d6c68b2ee18686a1c3a3f4
7
+ data.tar.gz: d21363ec67aa080df5ff76747cf6f97db0b81963fe66716e7df44e44456dab3cf5b528e502f43c441a967a8d029459d2bb1dcef343b215979524bcc9c068bbbe
@@ -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
- 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.
3
+ Instagram DMs, comments, and publishing for Rails, over the official Meta Graph API.
7
4
 
5
+ [![Gem Version](https://img.shields.io/gem/v/instagram_connect.svg)](https://rubygems.org/gems/instagram_connect)
8
6
  [![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.
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
8
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.1-CC342D.svg)](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
- - **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
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
- - **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.
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
- - 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)
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 + mounts the engine
50
- bin/rails db:migrate # runs the gem's migrations in place
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 mounts
54
- the engine at `/instagram`. **Migrations ship inside the gem and run in place** —
55
- nothing is copied into your app, so every adopter gets exactly the same schema,
56
- versioned with the gem. That's the whole install: **configuration only, no copied
57
- migrations, views, or CSS.**
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`. They authorize with Meta and are
85
- redirected back to `config.after_connect_redirect` with a stored, encrypted
86
- `InstagramConnect::Account`. Schedule the refresh job daily so tokens stay fresh:
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
- # config/recurring.yml (Solid Queue), or your scheduler of choice
90
- instagram_connect_token_refresh:
91
- class: InstagramConnect::RefreshTokensJob
92
- schedule: every day at 3am
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
- ## Webhooks
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
- Point your Meta app's webhook at `https://<your-host>/instagram/webhooks` and use the
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
- ## The inbox UI
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
- Mounted at `/instagram`: a self-contained, self-styled DM inbox (window-aware reply
105
- composer), comment moderation, and publishing — its own chrome, like an admin engine.
106
- Nothing to build in your app.
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
- ### Theming
294
+ ## The inbox UI and theming
109
295
 
110
- Tint the whole UI to your brand from the initializer no CSS or view files in your
111
- app. Any subset of keys is merged over the gem defaults:
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
- Deeper control (optional): render inside your own layout with
122
- `config.inherit_host_layout = true` (then add `<%= instagram_connect_styles %>` to
123
- your `<head>`), or copy the views/controllers to fully override them:
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 # preflight your configuration
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 **App Review** and
139
- **Business Verification**. Until approved, you can only message accounts added as
140
- testers on your Meta app. The full checklist — permissions per auth path, privacy
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
- ## Constraints (Meta rules, not the gem's)
365
+ ## Meta platform constraints
145
366
 
146
- - **24-hour window**: you can only reply within 24h of the customer's last message
147
- (7 days with the human-agent tag). The gem enforces this and blocks illegal sends.
148
- - **No cold DMs**: you can't message someone who never messaged you.
149
- - **Publishing**: 100 API-published posts per rolling 24h; media must be at a public
150
- HTTPS URL.
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 # in-memory sqlite, WebMock-stubbed Graph API, 100% coverage
384
+ bundle exec rspec
156
385
  bundle exec rubocop
157
386
  ```
158
387
 
159
- ## Roadmap
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
- See [docs/roadmap.md](docs/roadmap.md) next up: Turbo realtime broadcasting and
162
- Active Storage media (inbound fetch + outbound file send).
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
 
@@ -53,7 +53,7 @@ module InstagramConnect
53
53
 
54
54
  def client_for(comment)
55
55
  account = comment.account
56
- Client.new(access_token: account.access_token, ig_user_id: account.ig_user_id)
56
+ account.client
57
57
  end
58
58
  end
59
59
  end