instagram_connect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +41 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +30 -0
- data/Gemfile +16 -0
- data/LICENSE.txt +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +145 -0
- data/Rakefile +7 -0
- data/app/controllers/instagram_connect/application_controller.rb +30 -0
- data/app/controllers/instagram_connect/comments_controller.rb +59 -0
- data/app/controllers/instagram_connect/conversations_controller.rb +17 -0
- data/app/controllers/instagram_connect/messages_controller.rb +19 -0
- data/app/controllers/instagram_connect/oauth_controller.rb +43 -0
- data/app/controllers/instagram_connect/posts_controller.rb +40 -0
- data/app/controllers/instagram_connect/webhooks_controller.rb +54 -0
- data/app/jobs/instagram_connect/application_job.rb +4 -0
- data/app/jobs/instagram_connect/ingest_job.rb +11 -0
- data/app/jobs/instagram_connect/refresh_tokens_job.rb +22 -0
- data/app/jobs/instagram_connect/send_message_job.rb +44 -0
- data/app/models/instagram_connect/account.rb +36 -0
- data/app/models/instagram_connect/application_record.rb +5 -0
- data/app/models/instagram_connect/comment.rb +26 -0
- data/app/models/instagram_connect/conversation.rb +43 -0
- data/app/models/instagram_connect/inbound_message.rb +15 -0
- data/app/models/instagram_connect/message.rb +37 -0
- data/app/views/instagram_connect/comments/_comment.html.erb +19 -0
- data/app/views/instagram_connect/comments/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_composer.html.erb +15 -0
- data/app/views/instagram_connect/conversations/_conversation.html.erb +11 -0
- data/app/views/instagram_connect/conversations/_message.html.erb +6 -0
- data/app/views/instagram_connect/conversations/index.html.erb +11 -0
- data/app/views/instagram_connect/conversations/show.html.erb +12 -0
- data/app/views/instagram_connect/posts/index.html.erb +23 -0
- data/app/views/instagram_connect/posts/new.html.erb +17 -0
- data/app/views/layouts/instagram_connect/application.html.erb +19 -0
- data/bin/instagram_connect +6 -0
- data/config/routes.rb +28 -0
- data/docs/app_review_guide.md +57 -0
- data/docs/auth_paths.md +42 -0
- data/docs/roadmap.md +27 -0
- data/lib/generators/instagram_connect/controllers_generator.rb +16 -0
- data/lib/generators/instagram_connect/install/install_generator.rb +40 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt +17 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt +16 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_inbound_messages.rb.tt +11 -0
- data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt +25 -0
- data/lib/generators/instagram_connect/install/templates/instagram_connect.rb.tt +35 -0
- data/lib/generators/instagram_connect/views_generator.rb +16 -0
- data/lib/instagram_connect/auth/facebook_login.rb +68 -0
- data/lib/instagram_connect/auth/instagram_login.rb +73 -0
- data/lib/instagram_connect/auth/strategy.rb +70 -0
- data/lib/instagram_connect/auth.rb +17 -0
- data/lib/instagram_connect/cli.rb +18 -0
- data/lib/instagram_connect/client.rb +160 -0
- data/lib/instagram_connect/configuration.rb +66 -0
- data/lib/instagram_connect/connect.rb +45 -0
- data/lib/instagram_connect/doctor.rb +22 -0
- data/lib/instagram_connect/engine.rb +28 -0
- data/lib/instagram_connect/errors.rb +25 -0
- data/lib/instagram_connect/ingest.rb +112 -0
- data/lib/instagram_connect/messaging_window.rb +42 -0
- data/lib/instagram_connect/railtie.rb +19 -0
- data/lib/instagram_connect/result.rb +34 -0
- data/lib/instagram_connect/signature_verifier.rb +23 -0
- data/lib/instagram_connect/version.rb +3 -0
- data/lib/instagram_connect.rb +53 -0
- metadata +160 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# A connected Instagram professional account. Holds the (encrypted) access
|
|
3
|
+
# token and the identity the Graph client sends as. One row per connected
|
|
4
|
+
# account — the gem supports connecting several.
|
|
5
|
+
class Account < ApplicationRecord
|
|
6
|
+
self.table_name = "instagram_connect_accounts"
|
|
7
|
+
|
|
8
|
+
has_many :conversations, class_name: "InstagramConnect::Conversation",
|
|
9
|
+
foreign_key: :account_id, dependent: :destroy
|
|
10
|
+
has_many :comments, class_name: "InstagramConnect::Comment",
|
|
11
|
+
foreign_key: :account_id, dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :ig_user_id, presence: true, uniqueness: true
|
|
14
|
+
validates :auth_path, presence: true
|
|
15
|
+
|
|
16
|
+
scope :active, -> { where(active: true) }
|
|
17
|
+
scope :token_expiring_before, ->(time) { where.not(token_expires_at: nil).where(token_expires_at: ..time) }
|
|
18
|
+
|
|
19
|
+
# Called from the engine initializer (and specs) when token encryption is
|
|
20
|
+
# enabled. Kept as an explicit toggle so a host without Active Record
|
|
21
|
+
# Encryption configured can opt out via config.encrypt_tokens = false.
|
|
22
|
+
def self.enable_token_encryption!
|
|
23
|
+
encrypts :access_token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def token_expired?
|
|
27
|
+
token_expires_at.present? && token_expires_at <= Time.current
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Refresh via the account's auth strategy and persist the rotated token.
|
|
31
|
+
def refresh_access_token!
|
|
32
|
+
data = InstagramConnect::Auth.for(InstagramConnect.configuration).refresh_token(access_token: access_token)
|
|
33
|
+
update!(access_token: data[:access_token], token_expires_at: data[:expires_at])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# A comment on one of the account's media objects. Upserted from the
|
|
3
|
+
# `comments` webhook; moderation state (hidden/replied) is tracked here.
|
|
4
|
+
class Comment < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_comments"
|
|
6
|
+
|
|
7
|
+
belongs_to :account, class_name: "InstagramConnect::Account"
|
|
8
|
+
|
|
9
|
+
validates :comment_id, presence: true, uniqueness: true
|
|
10
|
+
|
|
11
|
+
scope :visible, -> { where(hidden_at: nil) }
|
|
12
|
+
|
|
13
|
+
# Upsert by Meta comment id — the same comment can arrive more than once.
|
|
14
|
+
def self.record(account:, comment_id:, media_id:, text:, from_username:, parent_id: nil)
|
|
15
|
+
comment = find_or_initialize_by(comment_id: comment_id)
|
|
16
|
+
comment.account = account
|
|
17
|
+
comment.assign_attributes(media_id: media_id, text: text, from_username: from_username, parent_id: parent_id)
|
|
18
|
+
comment.save!
|
|
19
|
+
comment
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def hidden?
|
|
23
|
+
hidden_at.present?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One DM thread — a connected account talking to one Instagram user (igsid).
|
|
3
|
+
# Threads are shared across the host's operators; ownership lives on the
|
|
4
|
+
# account, not per-operator.
|
|
5
|
+
class Conversation < ApplicationRecord
|
|
6
|
+
self.table_name = "instagram_connect_conversations"
|
|
7
|
+
|
|
8
|
+
belongs_to :account, class_name: "InstagramConnect::Account"
|
|
9
|
+
has_many :messages, class_name: "InstagramConnect::Message",
|
|
10
|
+
foreign_key: :conversation_id, dependent: :destroy
|
|
11
|
+
|
|
12
|
+
validates :igsid, presence: true, uniqueness: { scope: :account_id }
|
|
13
|
+
|
|
14
|
+
scope :unread, -> { where("unread_count > 0") }
|
|
15
|
+
scope :recent, -> { order(Arel.sql("last_message_at IS NULL, last_message_at DESC")) }
|
|
16
|
+
|
|
17
|
+
# Race-safe find-or-create on the unique [account_id, igsid] pair.
|
|
18
|
+
def self.locate(account:, igsid:)
|
|
19
|
+
find_or_create_by!(account_id: account.id, igsid: igsid)
|
|
20
|
+
rescue ActiveRecord::RecordNotUnique
|
|
21
|
+
find_by!(account_id: account.id, igsid: igsid)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Denormalizes the thread summary + unread count atomically (SQL-side) so
|
|
25
|
+
# concurrent inbound writes can't lose an unread increment.
|
|
26
|
+
def register_message(message)
|
|
27
|
+
stamp = message.created_at || Time.current
|
|
28
|
+
if message.inbound?
|
|
29
|
+
self.class.where(id: id).update_all([
|
|
30
|
+
"last_message_at = ?, last_message_preview = ?, last_inbound_at = ?, " \
|
|
31
|
+
"unread_count = unread_count + 1, updated_at = ?",
|
|
32
|
+
stamp, message.preview, stamp, Time.current
|
|
33
|
+
])
|
|
34
|
+
else
|
|
35
|
+
self.class.where(id: id).update_all([
|
|
36
|
+
"last_message_at = ?, last_message_preview = ?, updated_at = ?",
|
|
37
|
+
stamp, message.preview, Time.current
|
|
38
|
+
])
|
|
39
|
+
end
|
|
40
|
+
reload
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# At-least-once dedupe ledger keyed by Meta's message id. Lets the webhook
|
|
3
|
+
# (and any future backfill/poll) run without double-processing a message.
|
|
4
|
+
class InboundMessage < ApplicationRecord
|
|
5
|
+
self.table_name = "instagram_connect_inbound_messages"
|
|
6
|
+
|
|
7
|
+
# Returns true only the first time a given message id is claimed.
|
|
8
|
+
def self.claim(ig_message_id:, account_id: nil)
|
|
9
|
+
create!(ig_message_id: ig_message_id, account_id: account_id, processed_at: Time.current)
|
|
10
|
+
true
|
|
11
|
+
rescue ActiveRecord::RecordNotUnique
|
|
12
|
+
false
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module InstagramConnect
|
|
2
|
+
# One message bubble in a DM thread (or a story reply mirrored as a DM).
|
|
3
|
+
class Message < ApplicationRecord
|
|
4
|
+
self.table_name = "instagram_connect_messages"
|
|
5
|
+
|
|
6
|
+
DIRECTIONS = %w[inbound outbound].freeze
|
|
7
|
+
STATUSES = %w[received pending sending sent failed].freeze
|
|
8
|
+
KINDS = %w[dm story_reply reaction].freeze
|
|
9
|
+
SOURCES = %w[inbound manual api operator_app].freeze
|
|
10
|
+
MEDIA_STATUSES = %w[none pending downloadable attached unavailable].freeze
|
|
11
|
+
PREVIEW_LIMIT = 140
|
|
12
|
+
|
|
13
|
+
belongs_to :conversation, class_name: "InstagramConnect::Conversation"
|
|
14
|
+
|
|
15
|
+
validates :direction, inclusion: { in: DIRECTIONS }
|
|
16
|
+
validates :status, inclusion: { in: STATUSES }
|
|
17
|
+
validates :kind, inclusion: { in: KINDS }
|
|
18
|
+
|
|
19
|
+
scope :inbound, -> { where(direction: "inbound") }
|
|
20
|
+
scope :outbound, -> { where(direction: "outbound") }
|
|
21
|
+
scope :chronological, -> { order(:created_at, :id) }
|
|
22
|
+
|
|
23
|
+
def inbound?
|
|
24
|
+
direction == "inbound"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def outbound?
|
|
28
|
+
direction == "outbound"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Short summary used for the inbox row preview.
|
|
32
|
+
def preview
|
|
33
|
+
return body.to_s.truncate(PREVIEW_LIMIT) if body.present?
|
|
34
|
+
"[#{kind}]"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<li class="ic-comment <%= 'ic-comment--hidden' if comment.hidden? %>" id="<%= dom_id(comment) %>">
|
|
2
|
+
<span class="ic-comment__author"><%= comment.from_username %></span>
|
|
3
|
+
<span class="ic-comment__text"><%= comment.text %></span>
|
|
4
|
+
|
|
5
|
+
<div class="ic-comment__actions">
|
|
6
|
+
<%= form_with url: reply_comment_path(comment), method: :post, class: "ic-comment__reply" do |f| %>
|
|
7
|
+
<%= f.text_field :text, placeholder: "Reply…", class: "ic-comment__reply-input" %>
|
|
8
|
+
<%= f.submit "Reply" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<% if comment.hidden? %>
|
|
12
|
+
<%= button_to "Unhide", unhide_comment_path(comment), method: :post %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= button_to "Hide", hide_comment_path(comment), method: :post %>
|
|
15
|
+
<% end %>
|
|
16
|
+
|
|
17
|
+
<%= button_to "Delete", comment_path(comment), method: :delete, data: { turbo_confirm: "Delete this comment?" } %>
|
|
18
|
+
</div>
|
|
19
|
+
</li>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<section class="ic-comments">
|
|
2
|
+
<h1 class="ic-comments__title">Comments</h1>
|
|
3
|
+
|
|
4
|
+
<% if @comments.any? %>
|
|
5
|
+
<ul class="ic-comments__list" id="instagram_connect_comments">
|
|
6
|
+
<%= render partial: "comment", collection: @comments, as: :comment %>
|
|
7
|
+
</ul>
|
|
8
|
+
<% else %>
|
|
9
|
+
<p class="ic-empty">No comments captured yet. They appear here as people comment on your posts.</p>
|
|
10
|
+
<% end %>
|
|
11
|
+
</section>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% if window.open? %>
|
|
2
|
+
<%= form_with url: conversation_messages_path(conversation), method: :post, class: "ic-composer" do |f| %>
|
|
3
|
+
<%= f.text_area :body, class: "ic-composer__input", placeholder: "Write a reply…", rows: 2 %>
|
|
4
|
+
<% if window.state == :human_agent %>
|
|
5
|
+
<p class="ic-composer__note">
|
|
6
|
+
Outside the 24-hour window — this reply is sent under the human-agent tag (valid up to 7 days).
|
|
7
|
+
</p>
|
|
8
|
+
<% end %>
|
|
9
|
+
<%= f.submit "Send", class: "ic-composer__send" %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% else %>
|
|
12
|
+
<p class="ic-composer__closed">
|
|
13
|
+
The reply window has closed. The customer must message you again before you can reply.
|
|
14
|
+
</p>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<li class="ic-conversation" id="<%= dom_id(conversation) %>">
|
|
2
|
+
<%= link_to conversation_path(conversation), class: "ic-conversation__link" do %>
|
|
3
|
+
<span class="ic-conversation__name">
|
|
4
|
+
<%= conversation.display_name.presence || conversation.username.presence || conversation.igsid %>
|
|
5
|
+
</span>
|
|
6
|
+
<span class="ic-conversation__preview"><%= conversation.last_message_preview %></span>
|
|
7
|
+
<% if conversation.unread_count.positive? %>
|
|
8
|
+
<span class="ic-conversation__unread"><%= conversation.unread_count %></span>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
</li>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<div class="ic-message ic-message--<%= message.direction %>" id="<%= dom_id(message) %>">
|
|
2
|
+
<div class="ic-message__body"><%= message.body.presence || "[#{message.kind}]" %></div>
|
|
3
|
+
<span class="ic-message__meta">
|
|
4
|
+
<%= message.status %><%= " · #{message.failure_reason}" if message.failure_reason.present? %>
|
|
5
|
+
</span>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<section class="ic-inbox">
|
|
2
|
+
<h1 class="ic-inbox__title">Instagram inbox</h1>
|
|
3
|
+
|
|
4
|
+
<% if @conversations.any? %>
|
|
5
|
+
<ul class="ic-conversations" id="instagram_connect_conversations">
|
|
6
|
+
<%= render @conversations %>
|
|
7
|
+
</ul>
|
|
8
|
+
<% else %>
|
|
9
|
+
<p class="ic-empty">No conversations yet. They appear here as soon as someone messages your account.</p>
|
|
10
|
+
<% end %>
|
|
11
|
+
</section>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<section class="ic-thread-view">
|
|
2
|
+
<p class="ic-thread-view__back"><%= link_to "← Inbox", conversations_path %></p>
|
|
3
|
+
<h1 class="ic-thread-view__title">
|
|
4
|
+
<%= @conversation.display_name.presence || @conversation.username.presence || @conversation.igsid %>
|
|
5
|
+
</h1>
|
|
6
|
+
|
|
7
|
+
<div class="ic-thread" id="instagram_connect_messages">
|
|
8
|
+
<%= render partial: "message", collection: @messages, as: :message %>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= render "composer", conversation: @conversation, window: @window %>
|
|
12
|
+
</section>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<section class="ic-posts">
|
|
2
|
+
<h1 class="ic-posts__title">Your posts</h1>
|
|
3
|
+
<p class="ic-posts__actions"><%= link_to "Publish a post", new_post_path, class: "ic-posts__new" %></p>
|
|
4
|
+
|
|
5
|
+
<% if @quota_usage %>
|
|
6
|
+
<p class="ic-posts__quota">Published in the last 24 hours: <%= @quota_usage %> / 100</p>
|
|
7
|
+
<% end %>
|
|
8
|
+
|
|
9
|
+
<% if @media.any? %>
|
|
10
|
+
<ul class="ic-posts__list" id="instagram_connect_media">
|
|
11
|
+
<% @media.each do |item| %>
|
|
12
|
+
<li class="ic-post" id="ic-media-<%= item["id"] %>">
|
|
13
|
+
<span class="ic-post__caption"><%= item["caption"] %></span>
|
|
14
|
+
<% if item["permalink"].present? %>
|
|
15
|
+
<%= link_to "View on Instagram", item["permalink"], class: "ic-post__link" %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</li>
|
|
18
|
+
<% end %>
|
|
19
|
+
</ul>
|
|
20
|
+
<% else %>
|
|
21
|
+
<p class="ic-empty">No posts published through the API yet.</p>
|
|
22
|
+
<% end %>
|
|
23
|
+
</section>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<section class="ic-publish">
|
|
2
|
+
<h1 class="ic-publish__title">Publish a post</h1>
|
|
3
|
+
|
|
4
|
+
<%= form_with url: posts_path, method: :post, class: "ic-publish__form" do |f| %>
|
|
5
|
+
<div class="ic-field">
|
|
6
|
+
<%= f.label :image_url, "Image URL (public HTTPS)" %>
|
|
7
|
+
<%= f.url_field :image_url, required: true, class: "ic-field__input" %>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="ic-field">
|
|
10
|
+
<%= f.label :caption %>
|
|
11
|
+
<%= f.text_area :caption, rows: 3, class: "ic-field__input" %>
|
|
12
|
+
</div>
|
|
13
|
+
<%= f.submit "Publish", class: "ic-publish__submit" %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<p class="ic-publish__hint">The image must be reachable at a public HTTPS URL at publish time.</p>
|
|
17
|
+
</section>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Instagram — instagram_connect</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= yield :head %>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="ic-body">
|
|
10
|
+
<nav class="ic-nav">
|
|
11
|
+
<%= link_to "Inbox", conversations_path, class: "ic-nav__link" %>
|
|
12
|
+
<%= link_to "Comments", comments_path, class: "ic-nav__link" %>
|
|
13
|
+
<%= link_to "Publish", posts_path, class: "ic-nav__link" %>
|
|
14
|
+
</nav>
|
|
15
|
+
<% if flash[:notice] %><p class="ic-flash ic-flash--notice"><%= flash[:notice] %></p><% end %>
|
|
16
|
+
<% if flash[:alert] %><p class="ic-flash ic-flash--alert"><%= flash[:alert] %></p><% end %>
|
|
17
|
+
<main class="ic-main"><%= yield %></main>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
InstagramConnect::Engine.routes.draw do
|
|
2
|
+
# Meta webhook: GET verify handshake, POST event delivery.
|
|
3
|
+
get "webhooks", to: "webhooks#verify"
|
|
4
|
+
post "webhooks", to: "webhooks#receive"
|
|
5
|
+
|
|
6
|
+
# OAuth connect flow.
|
|
7
|
+
get "oauth/start", to: "oauth#start"
|
|
8
|
+
get "oauth/callback", to: "oauth#callback"
|
|
9
|
+
|
|
10
|
+
# DM inbox.
|
|
11
|
+
resources :conversations, only: %i[index show] do
|
|
12
|
+
resources :messages, only: :create
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Comment moderation.
|
|
16
|
+
resources :comments, only: %i[index destroy] do
|
|
17
|
+
member do
|
|
18
|
+
post :reply
|
|
19
|
+
post :hide
|
|
20
|
+
post :unhide
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Publishing.
|
|
25
|
+
resources :posts, only: %i[index new create]
|
|
26
|
+
|
|
27
|
+
root to: "conversations#index"
|
|
28
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Meta App Review checklist
|
|
2
|
+
|
|
3
|
+
To message real Instagram users (anyone without a role on your Meta app) you need
|
|
4
|
+
**Advanced Access**, which requires **App Review** *and* **Business Verification**.
|
|
5
|
+
Until then, development/standard access lets you message only accounts you add as
|
|
6
|
+
app roles/testers (~25). Budget weeks — the queue is slow and each rejection adds days.
|
|
7
|
+
|
|
8
|
+
## Permissions to request
|
|
9
|
+
|
|
10
|
+
**Instagram Login path** (`graph.instagram.com`):
|
|
11
|
+
|
|
12
|
+
- `instagram_business_basic`
|
|
13
|
+
- `instagram_business_manage_messages` — DMs
|
|
14
|
+
- `instagram_business_manage_comments` — comment moderation
|
|
15
|
+
- `instagram_business_content_publish` — publishing
|
|
16
|
+
- Human Agent — only if you need to reply beyond the 24-hour window (7-day human-agent
|
|
17
|
+
replies)
|
|
18
|
+
|
|
19
|
+
**Facebook Login path** (`graph.facebook.com`):
|
|
20
|
+
|
|
21
|
+
- `instagram_basic`, `instagram_manage_messages`, `instagram_manage_comments`,
|
|
22
|
+
`instagram_content_publish`
|
|
23
|
+
- `pages_show_list`, `pages_manage_metadata` (to subscribe the Page to webhooks),
|
|
24
|
+
`pages_read_engagement`
|
|
25
|
+
|
|
26
|
+
> Scope names in the `instagram_business_*` family were finalized on 27 Jan 2025 —
|
|
27
|
+
> confirm the exact current strings in Meta's live Permissions Reference before you
|
|
28
|
+
> submit.
|
|
29
|
+
|
|
30
|
+
## Before you submit
|
|
31
|
+
|
|
32
|
+
1. **Business Verification** in Meta Business Manager (legal name, address, and a
|
|
33
|
+
verifiable document / domain / phone).
|
|
34
|
+
2. A **public Privacy Policy URL** that clearly describes the data each requested
|
|
35
|
+
permission touches.
|
|
36
|
+
3. App set to **Live** mode, with an app icon and category.
|
|
37
|
+
4. **Test credentials + step-by-step reviewer instructions.**
|
|
38
|
+
5. A **screencast per permission** in English, showing the real end-to-end flow
|
|
39
|
+
(log in → connect the IG account → the actual DM read/send / comment / publish),
|
|
40
|
+
with narration or captions.
|
|
41
|
+
|
|
42
|
+
## Common rejection reasons
|
|
43
|
+
|
|
44
|
+
- Screencasts that use dummy data, lack narration, or don't actually demonstrate the
|
|
45
|
+
permission in use.
|
|
46
|
+
- Requesting more scopes than the demoed use case needs.
|
|
47
|
+
- A privacy policy that isn't publicly reachable or doesn't cover the requested data.
|
|
48
|
+
- No clear answer to "why does this app need this permission."
|
|
49
|
+
- App misconfiguration (not Live, missing test users, wrong app type).
|
|
50
|
+
- For messaging: missing opt-out/consent flows or inadequate webhook handling.
|
|
51
|
+
|
|
52
|
+
## Webhook subscription
|
|
53
|
+
|
|
54
|
+
Point the webhook at `https://<your-host>/instagram/webhooks`, set the verify token to
|
|
55
|
+
match `config.verify_token`, and subscribe: `messages`, `messaging_postbacks`,
|
|
56
|
+
`comments`, `mentions`. Meta must reach the URL publicly — localhost won't receive
|
|
57
|
+
callbacks; use a staging host or a tunnel while developing.
|
data/docs/auth_paths.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Auth paths
|
|
2
|
+
|
|
3
|
+
`instagram_connect` supports both of Meta's Instagram integration paths. Pick one via
|
|
4
|
+
`config.auth_path`. They differ in the Graph host, whether a Facebook Page is required,
|
|
5
|
+
the scope names, and the token lifetime.
|
|
6
|
+
|
|
7
|
+
## `:instagram_login` — Instagram API with Instagram Login
|
|
8
|
+
|
|
9
|
+
- **Host:** `graph.instagram.com`
|
|
10
|
+
- **Facebook Page:** not required — the operator logs in with Instagram credentials.
|
|
11
|
+
- **Scopes:** `instagram_business_basic`, `instagram_business_manage_messages`,
|
|
12
|
+
`instagram_business_manage_comments`, `instagram_business_content_publish`.
|
|
13
|
+
- **Tokens:** a 60-day long-lived token, refreshable (the `RefreshTokensJob` handles
|
|
14
|
+
this — run it daily).
|
|
15
|
+
- **Use when:** you're a single business connecting your own account, or you want the
|
|
16
|
+
lightest setup. This is Meta's recommended path for new integrations.
|
|
17
|
+
|
|
18
|
+
## `:facebook_login` — Instagram API with Facebook Login
|
|
19
|
+
|
|
20
|
+
- **Host:** `graph.facebook.com`
|
|
21
|
+
- **Facebook Page:** required — the Instagram professional account must be linked to a
|
|
22
|
+
Facebook Page inside Meta Business Manager.
|
|
23
|
+
- **Scopes:** `instagram_basic`, `instagram_manage_messages`,
|
|
24
|
+
`instagram_manage_comments`, `instagram_content_publish`, `pages_show_list`,
|
|
25
|
+
`pages_manage_metadata`, `pages_read_engagement`.
|
|
26
|
+
- **Tokens:** long-lived Page / Business-Manager System-User tokens, effectively
|
|
27
|
+
non-expiring (the refresh job treats them as a no-op).
|
|
28
|
+
- **Use when:** your account is already managed through a Facebook Page + Business
|
|
29
|
+
Manager, or you want the most durable server-to-server token story.
|
|
30
|
+
|
|
31
|
+
## Switching
|
|
32
|
+
|
|
33
|
+
Change `config.auth_path` and re-run the OAuth connect flow
|
|
34
|
+
(`GET /instagram/oauth/start`). Existing `Account` rows store the path they were
|
|
35
|
+
connected with. An app uses one path at a time.
|
|
36
|
+
|
|
37
|
+
## Adding a new path
|
|
38
|
+
|
|
39
|
+
Strategies are plain objects registered in `InstagramConnect::Auth::STRATEGIES` — the
|
|
40
|
+
same config-symbol dispatch used across the house gems. Implement `graph_host`,
|
|
41
|
+
`scopes`, `authorize_url`, `exchange_code`, and `refresh_token` on a subclass of
|
|
42
|
+
`InstagramConnect::Auth::Strategy` and add it to the registry.
|
data/docs/roadmap.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
## v0.1 (current)
|
|
4
|
+
|
|
5
|
+
- Both auth paths (Instagram Login / Facebook Login) via pluggable strategies
|
|
6
|
+
- Graph client (DMs, comments, publishing, reads, media) returning `Result`
|
|
7
|
+
- OAuth connect + encrypted-token accounts + scheduled token refresh
|
|
8
|
+
- HMAC-verified webhook + ingest (DMs incl. echoes, comments, postbacks) with dedupe
|
|
9
|
+
- Messaging-window enforcement (24h / 7d human-agent) + at-most-once send job
|
|
10
|
+
- Mounted inbox UI: DM inbox + composer, comment moderation, image publishing
|
|
11
|
+
- Generators (`install`, `views`, `controllers`) + `doctor` CLI
|
|
12
|
+
- 100% line + branch coverage on business logic
|
|
13
|
+
|
|
14
|
+
## v0.2 (next)
|
|
15
|
+
|
|
16
|
+
- **Turbo realtime broadcasting** — inbox rows and message bubbles update live via
|
|
17
|
+
Turbo Streams from model callbacks (no polling).
|
|
18
|
+
- **Active Storage media** — inbound media fetch + attach (MIME sniff + size checks),
|
|
19
|
+
and outbound file send via a signed, TTL'd blob URL that Meta fetches.
|
|
20
|
+
|
|
21
|
+
## Later
|
|
22
|
+
|
|
23
|
+
- Reels / video / carousel / story publishing (container status polling).
|
|
24
|
+
- Story mentions and replies (they arrive as DM events).
|
|
25
|
+
- Multiple connected accounts per host with per-account auth paths.
|
|
26
|
+
- Keyset pagination for high-volume inboxes.
|
|
27
|
+
- Optional AI reply drafting hook.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module InstagramConnect
|
|
4
|
+
module Generators
|
|
5
|
+
# `rails g instagram_connect:controllers` — copy the engine's controllers
|
|
6
|
+
# into the host app for deeper customization. Overridden controllers take
|
|
7
|
+
# precedence over the engine's.
|
|
8
|
+
class ControllersGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root InstagramConnect::Engine.root.join("app/controllers").to_s
|
|
10
|
+
|
|
11
|
+
def copy_controllers
|
|
12
|
+
directory "instagram_connect", "app/controllers/instagram_connect"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module InstagramConnect
|
|
5
|
+
module Generators
|
|
6
|
+
# `rails g instagram_connect:install` — writes the initializer, mounts the
|
|
7
|
+
# engine at /instagram, and copies the database migrations.
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
include ::Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
MIGRATIONS = %w[
|
|
14
|
+
create_instagram_connect_accounts
|
|
15
|
+
create_instagram_connect_conversations
|
|
16
|
+
create_instagram_connect_messages
|
|
17
|
+
create_instagram_connect_inbound_messages
|
|
18
|
+
create_instagram_connect_comments
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def self.next_migration_number(dirname)
|
|
22
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_initializer
|
|
26
|
+
template "instagram_connect.rb.tt", "config/initializers/instagram_connect.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mount_engine
|
|
30
|
+
route %(mount InstagramConnect::Engine => "/instagram")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def copy_migrations
|
|
34
|
+
MIGRATIONS.each do |name|
|
|
35
|
+
migration_template "#{name}.rb.tt", "db/migrate/#{name}.rb"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/generators/instagram_connect/install/templates/create_instagram_connect_accounts.rb.tt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateInstagramConnectAccounts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :instagram_connect_accounts do |t|
|
|
4
|
+
t.string :auth_path, null: false
|
|
5
|
+
t.string :ig_user_id, null: false
|
|
6
|
+
t.string :page_id
|
|
7
|
+
t.string :username
|
|
8
|
+
t.text :access_token
|
|
9
|
+
t.datetime :token_expires_at
|
|
10
|
+
t.boolean :active, default: true, null: false
|
|
11
|
+
t.bigint :connected_by_id
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :instagram_connect_accounts, :ig_user_id, unique: true
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/generators/instagram_connect/install/templates/create_instagram_connect_comments.rb.tt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class CreateInstagramConnectComments < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :instagram_connect_comments do |t|
|
|
4
|
+
t.bigint :account_id, null: false
|
|
5
|
+
t.string :media_id
|
|
6
|
+
t.string :comment_id, null: false
|
|
7
|
+
t.string :parent_id
|
|
8
|
+
t.string :from_username
|
|
9
|
+
t.text :text
|
|
10
|
+
t.datetime :hidden_at
|
|
11
|
+
t.datetime :replied_at
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :instagram_connect_comments, :comment_id, unique: true
|
|
15
|
+
add_index :instagram_connect_comments, :account_id
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/generators/instagram_connect/install/templates/create_instagram_connect_conversations.rb.tt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateInstagramConnectConversations < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :instagram_connect_conversations do |t|
|
|
4
|
+
t.bigint :account_id, null: false
|
|
5
|
+
t.string :igsid, null: false
|
|
6
|
+
t.string :username
|
|
7
|
+
t.string :display_name
|
|
8
|
+
t.datetime :last_message_at
|
|
9
|
+
t.datetime :last_inbound_at
|
|
10
|
+
t.string :last_message_preview
|
|
11
|
+
t.integer :unread_count, default: 0, null: false
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :instagram_connect_conversations, [ :account_id, :igsid ], unique: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class CreateInstagramConnectInboundMessages < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :instagram_connect_inbound_messages do |t|
|
|
4
|
+
t.string :ig_message_id, null: false
|
|
5
|
+
t.bigint :account_id
|
|
6
|
+
t.datetime :processed_at
|
|
7
|
+
t.timestamps
|
|
8
|
+
end
|
|
9
|
+
add_index :instagram_connect_inbound_messages, :ig_message_id, unique: true
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/generators/instagram_connect/install/templates/create_instagram_connect_messages.rb.tt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class CreateInstagramConnectMessages < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :instagram_connect_messages do |t|
|
|
4
|
+
t.bigint :conversation_id, null: false
|
|
5
|
+
t.string :direction, null: false
|
|
6
|
+
t.string :status, null: false
|
|
7
|
+
t.string :kind, default: "dm", null: false
|
|
8
|
+
t.string :source
|
|
9
|
+
t.text :body
|
|
10
|
+
t.string :ig_message_id
|
|
11
|
+
t.string :message_tag
|
|
12
|
+
t.bigint :sent_by_id
|
|
13
|
+
t.string :media_status, default: "none", null: false
|
|
14
|
+
t.string :media_mime
|
|
15
|
+
t.string :media_filename
|
|
16
|
+
t.integer :media_size
|
|
17
|
+
t.string :media_error
|
|
18
|
+
t.string :error_message
|
|
19
|
+
t.string :failure_reason
|
|
20
|
+
t.timestamps
|
|
21
|
+
end
|
|
22
|
+
add_index :instagram_connect_messages, :conversation_id
|
|
23
|
+
add_index :instagram_connect_messages, :ig_message_id, unique: true
|
|
24
|
+
end
|
|
25
|
+
end
|