instagram_connect 0.2.1 → 0.3.1
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 +102 -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 +150 -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/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 +129 -3
- data/lib/instagram_connect/configuration.rb +10 -0
- data/lib/instagram_connect/connect.rb +27 -4
- 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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class CreateInstagramConnectMessageAttachments < ActiveRecord::Migration[7.1]
|
|
2
|
+
# One row per file on a message, rather than the single set of media_* columns
|
|
3
|
+
# the messages table already carries.
|
|
4
|
+
#
|
|
5
|
+
# A DM can hold up to ten images, and each one fetches independently: one job
|
|
6
|
+
# per attachment means a ten-image message does not serialise behind the
|
|
7
|
+
# slowest file, and a retry ladder isolates to the file that actually failed.
|
|
8
|
+
def change
|
|
9
|
+
create_table :instagram_connect_message_attachments, if_not_exists: true do |t|
|
|
10
|
+
t.bigint :message_id, null: false
|
|
11
|
+
t.integer :position, null: false
|
|
12
|
+
t.string :kind, null: false
|
|
13
|
+
# Meta's CDN URL. Short-lived and not re-requestable, which is why the
|
|
14
|
+
# bytes are copied into the host's own storage rather than linked.
|
|
15
|
+
t.text :source_url
|
|
16
|
+
t.string :state, null: false, default: "pending"
|
|
17
|
+
t.string :mime
|
|
18
|
+
t.string :filename
|
|
19
|
+
t.bigint :size
|
|
20
|
+
t.string :error
|
|
21
|
+
# A reusable id from Meta's Attachment Upload API, so a canned reply image
|
|
22
|
+
# is uploaded once and sent by reference thereafter.
|
|
23
|
+
t.string :ig_attachment_id
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :instagram_connect_message_attachments, [ :message_id, :position ],
|
|
28
|
+
unique: true, if_not_exists: true
|
|
29
|
+
add_index :instagram_connect_message_attachments, [ :state, :created_at ],
|
|
30
|
+
name: "index_instagram_connect_attachments_on_state_and_time",
|
|
31
|
+
if_not_exists: true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class AddOutboundSendFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Individual add_column calls rather than a bulk change_table: only add_column
|
|
3
|
+
# honours if_not_exists, and every migration this gem ships has to survive
|
|
4
|
+
# being re-run against a database that already has the columns.
|
|
5
|
+
COLUMNS = {
|
|
6
|
+
# A message over Meta's 1000-byte ceiling goes out as several sends. This
|
|
7
|
+
# is the resume cursor, so a worker killed mid-fan-out picks up where it
|
|
8
|
+
# stopped instead of repeating parts the customer already received.
|
|
9
|
+
delivered_parts_count: { type: :integer, default: 0, null: false },
|
|
10
|
+
# The reusable id Meta returns from the Attachment Upload API, so a canned
|
|
11
|
+
# reply image is uploaded once and sent by reference afterwards.
|
|
12
|
+
attachment_upload_id: { type: :string }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def change
|
|
16
|
+
COLUMNS.each do |name, options|
|
|
17
|
+
add_column :instagram_connect_messages, name, options[:type],
|
|
18
|
+
**options.except(:type), if_not_exists: true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Composer idempotency: a double-submit carries the same token and the
|
|
22
|
+
# unique index turns the second one into a no-op rather than a second
|
|
23
|
+
# bubble the customer sees twice.
|
|
24
|
+
add_column :instagram_connect_messages, :client_token, :string, if_not_exists: true
|
|
25
|
+
add_index :instagram_connect_messages, :client_token, unique: true, if_not_exists: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInstagramConnectMedia < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Our own record of the account's posts, reels and stories.
|
|
3
|
+
#
|
|
4
|
+
# It exists because insights and mentions both need something to hang off, and
|
|
5
|
+
# because a story is gone from the API 24 hours after it is posted — the row
|
|
6
|
+
# has to outlive the thing it describes.
|
|
7
|
+
def change
|
|
8
|
+
create_table :instagram_connect_media, if_not_exists: true do |t|
|
|
9
|
+
t.bigint :account_id, null: false
|
|
10
|
+
t.string :ig_media_id, null: false
|
|
11
|
+
t.string :media_type
|
|
12
|
+
# FEED | STORY | REELS | AD. Distinct from media_type, and the field that
|
|
13
|
+
# decides which insight metrics Meta will even accept.
|
|
14
|
+
t.string :media_product_type
|
|
15
|
+
t.text :caption
|
|
16
|
+
t.text :permalink
|
|
17
|
+
t.text :media_url
|
|
18
|
+
t.text :thumbnail_url
|
|
19
|
+
t.datetime :posted_at
|
|
20
|
+
t.integer :comments_count
|
|
21
|
+
t.integer :like_count
|
|
22
|
+
t.datetime :synced_at
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_index :instagram_connect_media, [ :account_id, :ig_media_id ],
|
|
27
|
+
unique: true, if_not_exists: true
|
|
28
|
+
add_index :instagram_connect_media, [ :account_id, :posted_at ], if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateInstagramConnectMentions < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Every @mention of the account — in a comment, in a caption, or as a tag.
|
|
3
|
+
#
|
|
4
|
+
# This is a table rather than a query because **Meta does not store mention
|
|
5
|
+
# notifications**. There is no endpoint to ask "what did I miss": the webhook
|
|
6
|
+
# is delivered once and if it is not written down at that moment it is gone.
|
|
7
|
+
# For a travel brand these are the highest-value events on the platform —
|
|
8
|
+
# customers posting photos of a trip we sold them.
|
|
9
|
+
def change
|
|
10
|
+
create_table :instagram_connect_mentions, if_not_exists: true do |t|
|
|
11
|
+
t.bigint :account_id, null: false
|
|
12
|
+
t.string :kind, null: false
|
|
13
|
+
t.string :ig_media_id
|
|
14
|
+
t.string :comment_id
|
|
15
|
+
t.bigint :message_id
|
|
16
|
+
t.string :from_username
|
|
17
|
+
t.text :text
|
|
18
|
+
t.text :permalink
|
|
19
|
+
t.datetime :mentioned_at, null: false
|
|
20
|
+
t.datetime :replied_at
|
|
21
|
+
t.string :reply_comment_id
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
add_index :instagram_connect_mentions, [ :account_id, :kind, :ig_media_id, :comment_id ],
|
|
26
|
+
unique: true, name: "index_instagram_connect_mentions_unique", if_not_exists: true
|
|
27
|
+
add_index :instagram_connect_mentions, [ :account_id, :mentioned_at ],
|
|
28
|
+
name: "index_instagram_connect_mentions_on_account_and_time", if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
class CreateInstagramConnectInsightSnapshots < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Metric readings, kept because Meta does not keep them for long: story
|
|
3
|
+
# metrics are retrievable for 24 hours, account metrics for 90 days, media
|
|
4
|
+
# metrics for two years. A dashboard reading straight from the API can only
|
|
5
|
+
# ever show that window.
|
|
6
|
+
#
|
|
7
|
+
# Story insights are the acute case. The `story_insights` webhook fires when a
|
|
8
|
+
# story expires and carries its final metrics — that delivery is the only
|
|
9
|
+
# chance to record them, so its handler writes here synchronously rather than
|
|
10
|
+
# deferring to a job that might not run in time.
|
|
11
|
+
def change
|
|
12
|
+
create_table :instagram_connect_insight_snapshots, if_not_exists: true do |t|
|
|
13
|
+
t.bigint :account_id, null: false
|
|
14
|
+
t.string :subject_type, null: false
|
|
15
|
+
t.string :subject_ref
|
|
16
|
+
t.string :period, null: false
|
|
17
|
+
t.date :period_start, null: false
|
|
18
|
+
t.string :breakdown
|
|
19
|
+
# webhook | api. Part of the uniqueness key so the poller overwrites its
|
|
20
|
+
# own readings all day without ever colliding with the webhook's final
|
|
21
|
+
# one, and a dropped webhook still leaves the last mid-life reading.
|
|
22
|
+
t.string :source, null: false, default: "api"
|
|
23
|
+
t.column :metrics, json_type, null: false
|
|
24
|
+
# A metric Meta declines to report is absent from metrics rather than
|
|
25
|
+
# zero — these flags are how a UI tells "not reported" from "really zero".
|
|
26
|
+
t.boolean :empty, null: false, default: false
|
|
27
|
+
t.boolean :suppressed, null: false, default: false
|
|
28
|
+
t.integer :error_code
|
|
29
|
+
t.datetime :captured_at, null: false
|
|
30
|
+
t.timestamps
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :instagram_connect_insight_snapshots,
|
|
34
|
+
[ :account_id, :subject_type, :subject_ref, :period, :period_start, :breakdown, :source ],
|
|
35
|
+
unique: true, name: "index_instagram_connect_snapshots_unique", if_not_exists: true
|
|
36
|
+
add_index :instagram_connect_insight_snapshots, [ :account_id, :captured_at ],
|
|
37
|
+
name: "index_instagram_connect_snapshots_on_account_and_time", if_not_exists: true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def json_type
|
|
43
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class AddCommentModerationFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
COLUMNS = {
|
|
3
|
+
# The commenter's Instagram-scoped id. Needed to send them a private reply,
|
|
4
|
+
# which the username alone cannot address.
|
|
5
|
+
from_ig_id: { type: :string },
|
|
6
|
+
commented_at: { type: :datetime },
|
|
7
|
+
like_count: { type: :integer },
|
|
8
|
+
# From the live_comments field. A private reply to a live comment is valid
|
|
9
|
+
# only while the broadcast is running, so this is what stops the UI offering
|
|
10
|
+
# a button that is already guaranteed to fail.
|
|
11
|
+
is_live: { type: :boolean, default: false, null: false },
|
|
12
|
+
deleted_at: { type: :datetime },
|
|
13
|
+
# Distinct from replied_at, which is a public reply. The private reply is a
|
|
14
|
+
# separate, spendable resource: one per commenter, expiring 7 days after the
|
|
15
|
+
# comment.
|
|
16
|
+
private_replied_at: { type: :datetime },
|
|
17
|
+
private_reply_message_id: { type: :bigint }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def change
|
|
21
|
+
COLUMNS.each do |name, options|
|
|
22
|
+
add_column :instagram_connect_comments, name, options[:type],
|
|
23
|
+
**options.except(:type), if_not_exists: true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_index :instagram_connect_comments, [ :account_id, :commented_at ],
|
|
27
|
+
name: "index_instagram_connect_comments_on_account_and_time", if_not_exists: true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
class AddPerAccountTokenFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
ACCOUNT_COLUMNS = {
|
|
3
|
+
# The OAuth exchange returns a *user* token, but every Instagram messaging
|
|
4
|
+
# endpoint and the webhook subscription are Page-token operations. Keeping
|
|
5
|
+
# them in separate columns is what lets the readiness pass tell which grade
|
|
6
|
+
# it is holding instead of guessing.
|
|
7
|
+
page_access_token: { type: :text },
|
|
8
|
+
page_token_verified_at: { type: :datetime },
|
|
9
|
+
# Set when a Page call comes back with an auth error. There is no silent
|
|
10
|
+
# recovery from this — the operator has to reconnect — so it surfaces as a
|
|
11
|
+
# flag rather than as a retry that can never succeed.
|
|
12
|
+
needs_reconnect: { type: :boolean, default: false, null: false },
|
|
13
|
+
readiness_error: { type: :string },
|
|
14
|
+
# The fields the Page is actually subscribed to, as last read from Meta.
|
|
15
|
+
# Lets the readiness pass skip a POST it does not need, and lets a health
|
|
16
|
+
# screen show what is really live rather than what we hoped.
|
|
17
|
+
subscribed_fields: { type: :text },
|
|
18
|
+
subscriptions_synced_at: { type: :datetime },
|
|
19
|
+
name: { type: :string },
|
|
20
|
+
profile_picture_url: { type: :text },
|
|
21
|
+
profile_synced_at: { type: :datetime }
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def change
|
|
25
|
+
ACCOUNT_COLUMNS.each do |name, options|
|
|
26
|
+
add_column :instagram_connect_accounts, name, options[:type],
|
|
27
|
+
**options.except(:type), if_not_exists: true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
add_column :instagram_connect_messages, :account_id, :bigint, if_not_exists: true
|
|
31
|
+
|
|
32
|
+
reversible do |dir|
|
|
33
|
+
dir.up { backfill_message_accounts }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Uniqueness has to be per account, not global. With two connected accounts
|
|
37
|
+
# that message each other, the echo's mid is claimed by whichever processes
|
|
38
|
+
# first and permanently dropped for the other — a live correctness bug, not
|
|
39
|
+
# hygiene. Same reasoning for comments and the inbound ledger.
|
|
40
|
+
add_index :instagram_connect_messages, [ :account_id, :ig_message_id ],
|
|
41
|
+
unique: true, name: "index_instagram_connect_messages_on_account_and_mid",
|
|
42
|
+
if_not_exists: true
|
|
43
|
+
remove_index :instagram_connect_messages, column: :ig_message_id, if_exists: true
|
|
44
|
+
|
|
45
|
+
add_index :instagram_connect_comments, [ :account_id, :comment_id ],
|
|
46
|
+
unique: true, name: "index_instagram_connect_comments_on_account_and_comment",
|
|
47
|
+
if_not_exists: true
|
|
48
|
+
remove_index :instagram_connect_comments, column: :comment_id, if_exists: true
|
|
49
|
+
|
|
50
|
+
add_index :instagram_connect_inbound_messages, [ :account_id, :ig_message_id ],
|
|
51
|
+
unique: true, name: "index_instagram_connect_inbound_on_account_and_mid",
|
|
52
|
+
if_not_exists: true
|
|
53
|
+
remove_index :instagram_connect_inbound_messages, column: :ig_message_id, if_exists: true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Denormalized from the conversation so account-scoped inbox queries and
|
|
59
|
+
# stream names do not need a join. Filled inline because the new unique index
|
|
60
|
+
# is only meaningful once it is populated — a NULL account_id would let the
|
|
61
|
+
# very duplicates this index exists to prevent slip through.
|
|
62
|
+
#
|
|
63
|
+
# A correlated subquery rather than UPDATE...FROM, which is Postgres-only.
|
|
64
|
+
# An adopter with millions of messages should run this chunked before
|
|
65
|
+
# upgrading; at the scale this gem is written for it is a single indexed pass.
|
|
66
|
+
def backfill_message_accounts
|
|
67
|
+
execute(<<~SQL.squish)
|
|
68
|
+
UPDATE instagram_connect_messages
|
|
69
|
+
SET account_id = (
|
|
70
|
+
SELECT account_id FROM instagram_connect_conversations
|
|
71
|
+
WHERE instagram_connect_conversations.id = instagram_connect_messages.conversation_id
|
|
72
|
+
)
|
|
73
|
+
WHERE account_id IS NULL
|
|
74
|
+
SQL
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class CreateInstagramConnectApiBudgets < ActiveRecord::Migration[7.1]
|
|
2
|
+
# A shared, durable counter per account per rate-limit bucket.
|
|
3
|
+
#
|
|
4
|
+
# Meta's limits are per Instagram account, not per process, so a counter held
|
|
5
|
+
# in memory is wrong the moment there is more than one worker — and resets to
|
|
6
|
+
# zero on every deploy, which is exactly when a backlog is most likely to
|
|
7
|
+
# burst through the ceiling. A row with a unique key on the window makes the
|
|
8
|
+
# increment atomic across processes in one statement.
|
|
9
|
+
def change
|
|
10
|
+
create_table :instagram_connect_api_budgets, if_not_exists: true do |t|
|
|
11
|
+
t.bigint :account_id, null: false
|
|
12
|
+
t.string :bucket, null: false
|
|
13
|
+
t.datetime :window_start, null: false
|
|
14
|
+
t.integer :window_seconds, null: false
|
|
15
|
+
t.integer :used, null: false, default: 0
|
|
16
|
+
t.integer :limit_value, null: false
|
|
17
|
+
# Set when Meta says outright that we are throttled. Until it passes,
|
|
18
|
+
# every job for this account refuses immediately rather than each one
|
|
19
|
+
# rediscovering the block for itself.
|
|
20
|
+
t.datetime :blocked_until
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :instagram_connect_api_budgets, [ :account_id, :bucket, :window_start ],
|
|
25
|
+
unique: true, name: "index_instagram_connect_budgets_unique", if_not_exists: true
|
|
26
|
+
add_index :instagram_connect_api_budgets, [ :account_id, :bucket, :blocked_until ],
|
|
27
|
+
name: "index_instagram_connect_budgets_on_blocked", if_not_exists: true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class AddConversationProfileFields < ActiveRecord::Migration[7.1]
|
|
2
|
+
# username and display_name already existed on conversations and nothing ever
|
|
3
|
+
# wrote them, so an inbox showed raw Instagram-scoped ids — a 17-digit number
|
|
4
|
+
# where a person's handle belongs. These are the rest of what a thread row
|
|
5
|
+
# needs to look like a conversation with somebody.
|
|
6
|
+
def change
|
|
7
|
+
add_column :instagram_connect_conversations, :profile_picture_url, :text, if_not_exists: true
|
|
8
|
+
add_column :instagram_connect_conversations, :profile_synced_at, :datetime, if_not_exists: true
|
|
9
|
+
|
|
10
|
+
# Partial indexes are Postgres-only, and this gem ships into whatever
|
|
11
|
+
# database the host runs.
|
|
12
|
+
add_index :instagram_connect_conversations, [ :account_id, :profile_synced_at ],
|
|
13
|
+
name: "index_instagram_connect_conversations_on_profile_sync",
|
|
14
|
+
if_not_exists: true
|
|
15
|
+
|
|
16
|
+
# Cheap header stats, and the input to the 4800 x impressions budget the
|
|
17
|
+
# rate limiter falls back on when Meta sends no usage header.
|
|
18
|
+
add_column :instagram_connect_accounts, :followers_count, :integer, if_not_exists: true
|
|
19
|
+
add_column :instagram_connect_accounts, :media_count, :integer, if_not_exists: true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Configuration reference
|
|
2
|
+
|
|
3
|
+
Every setting lives on `InstagramConnect::Configuration` and is set inside the
|
|
4
|
+
`InstagramConnect.configure` block (usually
|
|
5
|
+
`config/initializers/instagram_connect.rb`):
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
InstagramConnect.configure do |config|
|
|
9
|
+
config.auth_path = :instagram_login
|
|
10
|
+
# ...
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
You can also set any of these through `config.instagram_connect = { ... }` in
|
|
15
|
+
your Rails app configuration; the engine (and the railtie) applies each key
|
|
16
|
+
through the matching setter at boot.
|
|
17
|
+
|
|
18
|
+
Several secrets fall back to environment variables, so a host can configure them
|
|
19
|
+
entirely from the environment without editing the initializer. The `configure`
|
|
20
|
+
block runs `validate!`, which currently checks only that `auth_path` is one of
|
|
21
|
+
the known values; missing secrets are reported later, at the point of use, and
|
|
22
|
+
by the `doctor` CLI.
|
|
23
|
+
|
|
24
|
+
## Core
|
|
25
|
+
|
|
26
|
+
| Setting | Type | Default | Environment fallback | Purpose |
|
|
27
|
+
| --- | --- | --- | --- | --- |
|
|
28
|
+
| `auth_path` | Symbol | `:instagram_login` | `INSTAGRAM_CONNECT_AUTH_PATH` | Which Meta login path and Graph host to use. One of `:instagram_login` (graph.instagram.com, no Facebook Page) or `:facebook_login` (graph.facebook.com, linked Page). Accepts a string and coerces it to a symbol. |
|
|
29
|
+
| `app_id` | String | `nil` | `INSTAGRAM_CONNECT_APP_ID`, then `INSTAGRAM_APP_ID` | Your Meta app's client id. |
|
|
30
|
+
| `app_secret` | String | `nil` | `INSTAGRAM_CONNECT_APP_SECRET`, then `INSTAGRAM_APP_SECRET` | Your Meta app's secret. Used for the OAuth code exchange and for verifying the webhook HMAC signature. |
|
|
31
|
+
| `verify_token` | String | `nil` | `INSTAGRAM_CONNECT_VERIFY_TOKEN`, then `INSTAGRAM_VERIFY_TOKEN` | The token you enter in the Meta webhook dashboard. The GET verification handshake must present this value. |
|
|
32
|
+
| `graph_version` | String | `"v21.0"` | `INSTAGRAM_CONNECT_GRAPH_VERSION` | The Graph API version segment in request URLs. |
|
|
33
|
+
| `redirect_uri` | String | `nil` | `INSTAGRAM_CONNECT_REDIRECT_URI` | Pin the OAuth redirect URI. Meta requires an exact match. When blank, the engine uses its own callback URL. |
|
|
34
|
+
|
|
35
|
+
## Token storage
|
|
36
|
+
|
|
37
|
+
| Setting | Type | Default | Purpose |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `encrypt_tokens` | Boolean | `true` | Encrypt `Account#access_token` at rest with Active Record Encryption. Set to `false` if your app has no encryption configured. When true, run `bin/rails db:encryption:init` once and add the generated keys to your credentials. |
|
|
40
|
+
|
|
41
|
+
## Rails integration
|
|
42
|
+
|
|
43
|
+
| Setting | Type | Default | Purpose |
|
|
44
|
+
| --- | --- | --- | --- |
|
|
45
|
+
| `parent_controller` | String | `"::ApplicationController"` | The controller the engine's UI controllers inherit from, so they pick up the host layout, auth helpers, and CSRF handling. The webhook controller does not inherit from this. |
|
|
46
|
+
| `authenticate_with` | Callable | `nil` | A lambda run as a `before_action` in the engine's controllers. Put your host's sign-in guard here, for example `-> { authenticate_user! }`. |
|
|
47
|
+
| `current_user_id_resolver` | Callable | resolves `current_user&.id` in controller context | Attributes outbound replies and connected accounts to the acting operator. Runs in controller context. |
|
|
48
|
+
| `inherit_host_layout` | Boolean | `false` | When `false`, the engine renders in its own bundled layout and stylesheet, like an admin engine. Set `true` to render inside your app's `application` layout, then add `<%= instagram_connect_styles %>` to your `<head>`. |
|
|
49
|
+
| `default_per_page` | Integer | `25` | Page size for the inbox and comment lists. |
|
|
50
|
+
| `after_connect_redirect` | String | `"/"` | Where the OAuth callback redirects after an account is connected. The install generator sets this to `/instagram/conversations`. |
|
|
51
|
+
|
|
52
|
+
## Event hooks
|
|
53
|
+
|
|
54
|
+
Each hook is optional and receives the persisted record (or event hash) after
|
|
55
|
+
the webhook payload has been ingested, so you can layer on notifications, AI
|
|
56
|
+
replies, and the like.
|
|
57
|
+
|
|
58
|
+
| Setting | Type | Default | Receives |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| `on_message` | Callable | `nil` | The `InstagramConnect::Message` created for an inbound DM (not fired for echoes of your own outbound sends). |
|
|
61
|
+
| `on_comment` | Callable | `nil` | The `InstagramConnect::Comment` recorded from a `comments` change. |
|
|
62
|
+
| `on_postback` | Callable | `nil` | A hash: `{ account_id:, sender_id:, payload:, title: }`. |
|
|
63
|
+
|
|
64
|
+
## Logging
|
|
65
|
+
|
|
66
|
+
| Setting | Type | Default | Purpose |
|
|
67
|
+
| --- | --- | --- | --- |
|
|
68
|
+
| `logger` | Logger | `Logger.new($stdout)` | Where the gem logs. The token refresh job logs per-account failures here. |
|
|
69
|
+
|
|
70
|
+
## Theme
|
|
71
|
+
|
|
72
|
+
The gem ships a complete, self-styled UI. Tint it by assigning `config.theme` a
|
|
73
|
+
hash of any subset of the keys below; your values are merged over the defaults
|
|
74
|
+
(`resolved_theme`) and emitted as CSS custom properties by
|
|
75
|
+
`instagram_connect_styles`.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
config.theme = { primary: "#0057a8", font: "Inter, system-ui, sans-serif", radius: "12px" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Key | Default |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `primary` | `#2563eb` |
|
|
84
|
+
| `primary_contrast` | `#ffffff` |
|
|
85
|
+
| `bg` | `#f7f8fa` |
|
|
86
|
+
| `surface` | `#ffffff` |
|
|
87
|
+
| `text` | `#111827` |
|
|
88
|
+
| `muted` | `#6b7280` |
|
|
89
|
+
| `border` | `#e5e7eb` |
|
|
90
|
+
| `radius` | `12px` |
|
|
91
|
+
| `font` | `Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif` |
|
|
92
|
+
| `customer_bubble` | `#f1f3f5` |
|
|
93
|
+
| `staff_bubble` | `#eef2ff` |
|
|
94
|
+
| `ok` | `#16a34a` |
|
|
95
|
+
| `warn` | `#d97706` |
|
|
96
|
+
| `err` | `#dc2626` |
|
|
97
|
+
|
|
98
|
+
## Media
|
|
99
|
+
|
|
100
|
+
These are defined on the configuration object with the defaults below. They are
|
|
101
|
+
reserved for the inbound and outbound media handling on the roadmap and are not
|
|
102
|
+
yet read by the shipped code.
|
|
103
|
+
|
|
104
|
+
| Setting | Type | Default |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| `media_max_bytes` | Integer | `26214400` (25 MB) |
|
|
107
|
+
| `allowed_media_types` | Array | `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `video/mp4`, `audio/mpeg`, `audio/aac`, `application/pdf` |
|
|
@@ -33,6 +33,61 @@ module InstagramConnect
|
|
|
33
33
|
})
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Typing indicators and read receipts. Meta is explicit that a sender action
|
|
37
|
+
# request must carry ONLY the recipient and the action — bundling it with a
|
|
38
|
+
# message silently drops one of the two.
|
|
39
|
+
def send_sender_action(recipient_id:, action:)
|
|
40
|
+
post("/me/messages", { recipient: { id: recipient_id }, sender_action: action })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_quick_replies(recipient_id:, text:, replies:, tag: nil)
|
|
44
|
+
quick_replies = Array(replies).map do |reply|
|
|
45
|
+
{ content_type: "text", title: reply[:title].to_s[0, 20], payload: reply[:payload].to_s }
|
|
46
|
+
end
|
|
47
|
+
send_message(recipient: { id: recipient_id },
|
|
48
|
+
message: { text: text, quick_replies: quick_replies }, tag: tag)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def send_attachment(recipient_id:, attachment_id:, tag: nil)
|
|
52
|
+
send_message(recipient: { id: recipient_id },
|
|
53
|
+
message: { attachment: { payload: { attachment_id: attachment_id } } }, tag: tag)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Uploads bytes to Meta and returns a reusable attachment id.
|
|
57
|
+
#
|
|
58
|
+
# Preferred over handing Meta a signed URL to the host's storage: a signed
|
|
59
|
+
# URL is a bearer capability for a customer's file, sitting on the public
|
|
60
|
+
# internet for as long as its TTL, fetched from an address we do not
|
|
61
|
+
# control. This moves the bytes over an authenticated POST instead, and the
|
|
62
|
+
# returned id can be reused rather than re-uploading the same file.
|
|
63
|
+
# +file+ must be a File or Tempfile — HTTParty builds the multipart body
|
|
64
|
+
# from objects that expose a path, which is also what ActiveStorage's
|
|
65
|
+
# blob.open yields.
|
|
66
|
+
def upload_attachment(file:, type: "image")
|
|
67
|
+
body = {
|
|
68
|
+
platform: "instagram",
|
|
69
|
+
message: { attachment: { type: type, payload: { is_reusable: true } } }.to_json,
|
|
70
|
+
filedata: file
|
|
71
|
+
}
|
|
72
|
+
post_multipart("/me/message_attachments", body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Ice breakers and the persistent menu both live on the messenger profile.
|
|
76
|
+
def set_messenger_profile(**fields)
|
|
77
|
+
post("/me/messenger_profile", fields.merge(platform: "instagram"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def messenger_profile(fields:)
|
|
81
|
+
get("/me/messenger_profile", { fields: Array(fields).join(","), platform: "instagram" })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delete_messenger_profile(fields:)
|
|
85
|
+
parse(HTTParty.delete(url("/me/messenger_profile"),
|
|
86
|
+
headers: bearer.merge("Content-Type" => "application/json"),
|
|
87
|
+
body: { fields: Array(fields), platform: "instagram" }.to_json,
|
|
88
|
+
timeout: TIMEOUT))
|
|
89
|
+
end
|
|
90
|
+
|
|
36
91
|
# One-time private reply to a comment (comment -> DM), valid 7 days.
|
|
37
92
|
def private_reply(comment_id:, text:)
|
|
38
93
|
post("/me/messages", { recipient: { comment_id: comment_id }, message: { text: text } })
|
|
@@ -85,6 +140,13 @@ module InstagramConnect
|
|
|
85
140
|
get("/#{media_id}/insights", { metric: Array(metrics).join(",") })
|
|
86
141
|
end
|
|
87
142
|
|
|
143
|
+
# The connected account's own profile. A different call from #profile,
|
|
144
|
+
# which looks up the person on the other end of a thread.
|
|
145
|
+
def account_profile(ig_user_id: @ig_user_id, fields: %w[username name profile_picture_url
|
|
146
|
+
followers_count media_count])
|
|
147
|
+
get("/#{require_ig_user_id(ig_user_id)}", { fields: Array(fields).join(",") })
|
|
148
|
+
end
|
|
149
|
+
|
|
88
150
|
def profile(igsid:, fields: %w[name username profile_pic])
|
|
89
151
|
get("/#{igsid}", { fields: Array(fields).join(",") })
|
|
90
152
|
end
|
|
@@ -95,6 +157,58 @@ module InstagramConnect
|
|
|
95
157
|
get("/me/accounts", { fields: "id,name,access_token,instagram_business_account" })
|
|
96
158
|
end
|
|
97
159
|
|
|
160
|
+
# One Page by id. /me/accounts answers for the Pages a token may *enumerate*;
|
|
161
|
+
# this answers for a Page it may *read*. Those are not the same set — a Page
|
|
162
|
+
# granted through the Login-for-Business asset picker is readable here while
|
|
163
|
+
# absent from the listing.
|
|
164
|
+
def page(page_id)
|
|
165
|
+
get("/#{page_id}", { fields: "id,name,access_token,instagram_business_account" })
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The asset ids this token actually carries, from Meta's own record of the
|
|
169
|
+
# consent. Needs no permission beyond the app's own credentials.
|
|
170
|
+
def granted_asset_ids
|
|
171
|
+
result = get("/debug_token", { input_token: access_token,
|
|
172
|
+
access_token: "#{config.app_id}|#{config.app_secret}" })
|
|
173
|
+
return [] unless result.success?
|
|
174
|
+
|
|
175
|
+
Array(result.data.dig("data", "granular_scopes"))
|
|
176
|
+
.flat_map { |scope| Array(scope["target_ids"]) }
|
|
177
|
+
.uniq
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Walks a Graph collection to the end, following Meta's own cursors.
|
|
181
|
+
#
|
|
182
|
+
# Offsets are not safe here: a collection can shift between calls, so an
|
|
183
|
+
# offset silently skips or repeats rows. +limit_pages+ exists because these
|
|
184
|
+
# collections can be unbounded and every page spends rate budget.
|
|
185
|
+
def each_page(path, query = {}, limit_pages: 25)
|
|
186
|
+
cursor = nil
|
|
187
|
+
pages = 0
|
|
188
|
+
|
|
189
|
+
while pages < limit_pages
|
|
190
|
+
page_query = cursor ? query.merge(after: cursor) : query
|
|
191
|
+
result = get(path, page_query)
|
|
192
|
+
return result unless result.success?
|
|
193
|
+
|
|
194
|
+
yield result
|
|
195
|
+
pages += 1
|
|
196
|
+
cursor = result.next_cursor
|
|
197
|
+
break if cursor.nil?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
Result.ok(data: { "pages" => pages })
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Every record across every page, for callers that just want the list.
|
|
204
|
+
def collect(path, query = {}, limit_pages: 25)
|
|
205
|
+
records = []
|
|
206
|
+
result = each_page(path, query, limit_pages: limit_pages) { |page| records.concat(page.records) }
|
|
207
|
+
return result if result.failure?
|
|
208
|
+
|
|
209
|
+
Result.ok(data: { "data" => records })
|
|
210
|
+
end
|
|
211
|
+
|
|
98
212
|
def fetch_media_binary(url:)
|
|
99
213
|
response = HTTParty.get(url, headers: bearer, timeout: TIMEOUT, follow_redirects: true)
|
|
100
214
|
unless response.success?
|
|
@@ -135,6 +249,13 @@ module InstagramConnect
|
|
|
135
249
|
parse(HTTParty.delete(url(path), headers: bearer, timeout: TIMEOUT))
|
|
136
250
|
end
|
|
137
251
|
|
|
252
|
+
# multipart/form-data rather than JSON. The Attachment Upload API takes raw
|
|
253
|
+
# bytes, which a JSON body cannot carry.
|
|
254
|
+
def post_multipart(path, body)
|
|
255
|
+
parse(HTTParty.post(url(path), headers: bearer, body: body,
|
|
256
|
+
multipart: true, timeout: TIMEOUT))
|
|
257
|
+
end
|
|
258
|
+
|
|
138
259
|
def url(path)
|
|
139
260
|
"#{strategy.graph_host}/#{config.graph_version}#{path}"
|
|
140
261
|
end
|
|
@@ -146,14 +267,19 @@ module InstagramConnect
|
|
|
146
267
|
def parse(response)
|
|
147
268
|
data = response.parsed_response
|
|
148
269
|
data = {} unless data.is_a?(Hash)
|
|
270
|
+
usage = Usage.from_headers(response.headers)
|
|
149
271
|
if response.success?
|
|
150
|
-
Result.ok(id: data["message_id"] || data["id"], data: data)
|
|
272
|
+
Result.ok(id: data["message_id"] || data["id"], data: data, usage: usage)
|
|
151
273
|
else
|
|
152
274
|
err = data["error"].is_a?(Hash) ? data["error"] : {}
|
|
153
275
|
Result.error(err["message"] || "HTTP #{response.code}",
|
|
154
276
|
error_code: err["code"],
|
|
155
|
-
|
|
156
|
-
|
|
277
|
+
# Meta names the wait in the error when it has one; when it
|
|
278
|
+
# does not, the usage header's estimate is the next best
|
|
279
|
+
# thing and is better than an invented backoff.
|
|
280
|
+
retry_after: err.dig("error_data", "retry_after") || usage&.retry_after,
|
|
281
|
+
data: data,
|
|
282
|
+
usage: usage)
|
|
157
283
|
end
|
|
158
284
|
end
|
|
159
285
|
end
|
|
@@ -41,6 +41,16 @@ module InstagramConnect
|
|
|
41
41
|
attr_reader :auth_path
|
|
42
42
|
attr_writer :theme
|
|
43
43
|
|
|
44
|
+
# A copy of this configuration pinned to one auth path. Accounts store the
|
|
45
|
+
# path they were connected with, and a host can have both — so the global
|
|
46
|
+
# setting is the default for *new* connections, never an override on an
|
|
47
|
+
# existing one.
|
|
48
|
+
def for_auth_path(path)
|
|
49
|
+
return self if path.blank? || path.to_sym == auth_path
|
|
50
|
+
|
|
51
|
+
dup.tap { |copy| copy.auth_path = path }
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
def initialize
|
|
45
55
|
@auth_path = (ENV["INSTAGRAM_CONNECT_AUTH_PATH"] || "instagram_login").to_sym
|
|
46
56
|
@app_id = ENV["INSTAGRAM_CONNECT_APP_ID"] || ENV["INSTAGRAM_APP_ID"]
|
|
@@ -35,11 +35,34 @@ module InstagramConnect
|
|
|
35
35
|
def resolve_identity(data)
|
|
36
36
|
return { ig_user_id: data[:ig_user_id], page_id: data[:page_id] } if data[:ig_user_id]
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
page =
|
|
40
|
-
|
|
38
|
+
client = Client.new(access_token: data[:access_token], config: @config)
|
|
39
|
+
page = candidate_pages(client).find { |p| p["instagram_business_account"] }
|
|
40
|
+
unless page
|
|
41
|
+
raise ConfigurationError,
|
|
42
|
+
"connected, but no Page with a linked Instagram business account was found — " \
|
|
43
|
+
"checked the Pages this token can list and the assets it was granted"
|
|
44
|
+
end
|
|
41
45
|
|
|
42
|
-
{ ig_user_id: page.dig("instagram_business_account", "id"), page_id: page["id"] }
|
|
46
|
+
{ ig_user_id: page.dig("instagram_business_account", "id").to_s, page_id: page["id"].to_s }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# /me/accounts lists only the Pages a token may enumerate, and a Page granted
|
|
50
|
+
# through the Login-for-Business asset picker is not one of them — the list
|
|
51
|
+
# comes back empty while the Page itself reads fine. So when the listing has
|
|
52
|
+
# nothing usable, ask the token which assets it carries and read those.
|
|
53
|
+
def candidate_pages(client)
|
|
54
|
+
listed = client.list_pages
|
|
55
|
+
unless listed.success?
|
|
56
|
+
raise ApiError, "could not read Pages from Instagram: #{listed.error_message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
pages = Array(listed.data["data"])
|
|
60
|
+
return pages if pages.any? { |p| p["instagram_business_account"] }
|
|
61
|
+
|
|
62
|
+
client.granted_asset_ids.filter_map do |id|
|
|
63
|
+
result = client.page(id)
|
|
64
|
+
result.data if result.success?
|
|
65
|
+
end
|
|
43
66
|
end
|
|
44
67
|
end
|
|
45
68
|
end
|